@getnori/feed 0.0.0-stage → 0.2.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 ADDED
@@ -0,0 +1,427 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ NoriFeed: () => NoriFeed,
34
+ ViewTracker: () => ViewTracker,
35
+ createViewTracker: () => createViewTracker,
36
+ useFeed: () => useFeed
37
+ });
38
+ module.exports = __toCommonJS(index_exports);
39
+
40
+ // src/NoriFeed.tsx
41
+ var import_react3 = require("react");
42
+ var import_react_native2 = require("react-native");
43
+
44
+ // src/ReelItem.tsx
45
+ var import_react = require("react");
46
+ var import_react_native = require("react-native");
47
+ var import_react_native_video = __toESM(require("react-native-video"), 1);
48
+ var import_jsx_runtime = require("react/jsx-runtime");
49
+ function ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay }) {
50
+ const [userPaused, setUserPaused] = (0, import_react.useState)(false);
51
+ const [positionMs, setPositionMs] = (0, import_react.useState)(0);
52
+ const durationRef = (0, import_react.useRef)(null);
53
+ const [durationMs, setDurationMs] = (0, import_react.useState)(null);
54
+ const isVideo = reel.media_type === "video" && Boolean(reel.media_url);
55
+ (0, import_react.useEffect)(() => {
56
+ if (!active) {
57
+ setUserPaused(false);
58
+ return;
59
+ }
60
+ tracker.activate(reel);
61
+ if (durationRef.current) tracker.loaded(reel.id, durationRef.current);
62
+ }, [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,
66
+ {
67
+ style: import_react_native.StyleSheet.absoluteFill,
68
+ onPress: () => isVideo && setUserPaused((p) => !p),
69
+ accessibilityRole: "button",
70
+ accessibilityLabel: userPaused ? "Play" : "Pause",
71
+ disabled: !isVideo,
72
+ children: isVideo ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
73
+ import_react_native_video.default,
74
+ {
75
+ testID: `nori-video-${reel.id}`,
76
+ source: { uri: reel.media_url },
77
+ style: VIDEO_STYLE,
78
+ resizeMode: "cover",
79
+ repeat: true,
80
+ muted,
81
+ paused: !active || userPaused,
82
+ progressUpdateInterval: 250,
83
+ playInBackground: false,
84
+ onLoad: (data) => {
85
+ const ms = data.duration * 1e3;
86
+ durationRef.current = ms;
87
+ setDurationMs(ms);
88
+ if (active) tracker.loaded(reel.id, ms);
89
+ },
90
+ onProgress: (data) => {
91
+ const ms = data.currentTime * 1e3;
92
+ setPositionMs(ms);
93
+ if (active) tracker.progress(reel.id, ms);
94
+ },
95
+ onEnd: () => active && tracker.ended(reel.id)
96
+ }
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
98
+ }
99
+ ),
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.ai_generated ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.View, { style: styles.aiBadge, accessibilityLabel: "AI-generated", testID: `nori-ai-label-${reel.id}`, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: styles.aiText, children: "AI-generated" }) }) : null,
103
+ reel.title ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: [styles.title, { color: textColor }], numberOfLines: 2, children: reel.title }) : null,
104
+ reel.description ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: [styles.description, { color: textColor }], numberOfLines: 3, children: reel.description }) : null
105
+ ] }),
106
+ 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
107
+ ] });
108
+ }
109
+ var ReelItem = (0, import_react.memo)(ReelItemImpl);
110
+ var VIDEO_STYLE = import_react_native.Platform.OS === "web" ? [import_react_native.StyleSheet.absoluteFill, { objectFit: "cover" }] : import_react_native.StyleSheet.absoluteFill;
111
+ var styles = import_react_native.StyleSheet.create({
112
+ reel: { width: "100%", overflow: "hidden", backgroundColor: "#000" },
113
+ caption: { position: "absolute", left: 16, right: 80, bottom: 120 },
114
+ title: { fontSize: 17, fontWeight: "700", marginBottom: 4, textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 4 },
115
+ description: { fontSize: 14, lineHeight: 19, textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 4 },
116
+ pausedBadge: {
117
+ position: "absolute",
118
+ alignSelf: "center",
119
+ top: "45%",
120
+ width: 64,
121
+ height: 64,
122
+ borderRadius: 32,
123
+ backgroundColor: "rgba(0,0,0,0.45)",
124
+ alignItems: "center",
125
+ justifyContent: "center"
126
+ },
127
+ pausedText: { color: "#fff", fontSize: 26, marginLeft: 4 },
128
+ aiBadge: {
129
+ alignSelf: "flex-start",
130
+ marginBottom: 6,
131
+ paddingHorizontal: 8,
132
+ paddingVertical: 3,
133
+ borderRadius: 10,
134
+ backgroundColor: "rgba(0,0,0,0.55)",
135
+ borderWidth: 1,
136
+ borderColor: "rgba(255,255,255,0.35)"
137
+ },
138
+ aiText: { color: "#fff", fontSize: 11, fontWeight: "700", letterSpacing: 0.3 }
139
+ });
140
+
141
+ // src/useFeed.ts
142
+ var import_react2 = require("react");
143
+ function useFeed(client, { tag = null, pageSize = 10 } = {}) {
144
+ const [reels, setReels] = (0, import_react2.useState)([]);
145
+ const [loading, setLoading] = (0, import_react2.useState)(true);
146
+ const [loadingMore, setLoadingMore] = (0, import_react2.useState)(false);
147
+ const [refreshing, setRefreshing] = (0, import_react2.useState)(false);
148
+ const [error, setError] = (0, import_react2.useState)(null);
149
+ const [holdout, setHoldout] = (0, import_react2.useState)(false);
150
+ const [cursor, setCursor] = (0, import_react2.useState)(null);
151
+ const [hasMore, setHasMore] = (0, import_react2.useState)(true);
152
+ const busy = (0, import_react2.useRef)(false);
153
+ const generation = (0, import_react2.useRef)(0);
154
+ const limit = Math.min(Math.max(1, Math.round(pageSize)), 50);
155
+ const load = (0, import_react2.useCallback)(
156
+ async (mode, fromCursor) => {
157
+ if (busy.current && mode === "more") return;
158
+ busy.current = true;
159
+ const gen = mode === "more" ? generation.current : ++generation.current;
160
+ if (mode === "more") setLoadingMore(true);
161
+ if (mode === "refresh") setRefreshing(true);
162
+ try {
163
+ const page = await client.feed({ limit, cursor: fromCursor, tag });
164
+ if (gen !== generation.current) return;
165
+ setError(null);
166
+ setHoldout(Boolean(page.holdout));
167
+ setCursor(page.next_cursor ?? null);
168
+ setHasMore(Boolean(page.next_cursor));
169
+ setReels((prev) => {
170
+ const base = mode === "more" ? prev : [];
171
+ const seen = new Set(base.map((r) => r.id));
172
+ return [...base, ...page.reels.filter((r) => !seen.has(r.id))];
173
+ });
174
+ } catch (e) {
175
+ if (gen === generation.current) setError(e);
176
+ } finally {
177
+ if (gen === generation.current) {
178
+ busy.current = false;
179
+ setLoading(false);
180
+ setLoadingMore(false);
181
+ setRefreshing(false);
182
+ }
183
+ }
184
+ },
185
+ [client, limit, tag]
186
+ );
187
+ (0, import_react2.useEffect)(() => {
188
+ setLoading(true);
189
+ setReels([]);
190
+ void load("first", null);
191
+ }, [load]);
192
+ const loadMore = (0, import_react2.useCallback)(async () => {
193
+ if (!hasMore || !cursor || loading || holdout) return;
194
+ await load("more", cursor);
195
+ }, [cursor, hasMore, holdout, load, loading]);
196
+ const refresh = (0, import_react2.useCallback)(() => load("refresh", null), [load]);
197
+ return { reels, loading, loadingMore, refreshing, error, holdout, hasMore, loadMore, refresh };
198
+ }
199
+
200
+ // src/viewTracker.ts
201
+ var PROGRESS_MILESTONES = [25, 50, 75];
202
+ var MAX_TICK_MS = 1500;
203
+ var ViewTracker = class {
204
+ constructor(track, options) {
205
+ this.track = track;
206
+ this.options = options;
207
+ this.impressions = /* @__PURE__ */ new Set();
208
+ this.completed = /* @__PURE__ */ new Set();
209
+ this.current = null;
210
+ this.now = options.now ?? (() => Date.now());
211
+ }
212
+ /** The reel is now the visible one. */
213
+ activate(reel) {
214
+ if (this.current?.reelId === reel.id) return;
215
+ this.current = {
216
+ reelId: reel.id,
217
+ durationMs: null,
218
+ milestones: /* @__PURE__ */ new Set(),
219
+ opened: false,
220
+ furthestMs: 0,
221
+ watchedMs: 0,
222
+ lastTickAt: null
223
+ };
224
+ if (!this.impressions.has(reel.id)) {
225
+ this.impressions.add(reel.id);
226
+ this.track({ type: "impression", reel_id: reel.id });
227
+ }
228
+ }
229
+ /** The visible video loaded and knows its duration. */
230
+ loaded(reelId, durationMs) {
231
+ const view = this.view(reelId);
232
+ if (!view || !(durationMs > 0)) return;
233
+ view.durationMs = Math.round(durationMs);
234
+ if (!view.opened) {
235
+ view.opened = true;
236
+ view.lastTickAt = this.now();
237
+ this.track({ type: "view_progress", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });
238
+ }
239
+ }
240
+ /** Playback position of the visible video (called while it plays). */
241
+ progress(reelId, positionMs) {
242
+ const view = this.view(reelId);
243
+ if (!view || !view.durationMs || !view.opened) return;
244
+ const now = this.now();
245
+ if (view.lastTickAt !== null) {
246
+ const gap = now - view.lastTickAt;
247
+ if (gap > 0 && gap <= MAX_TICK_MS) view.watchedMs += gap;
248
+ }
249
+ view.lastTickAt = now;
250
+ const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));
251
+ view.furthestMs = Math.max(view.furthestMs, position);
252
+ const percent = view.furthestMs / view.durationMs * 100;
253
+ if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {
254
+ const required = Math.max(
255
+ view.durationMs * this.options.minWatchPercent / 100,
256
+ (this.options.minViewSeconds ?? 0) * 1e3
257
+ );
258
+ if (view.watchedMs >= required) {
259
+ this.completed.add(reelId);
260
+ this.track({ type: "view_complete", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });
261
+ return;
262
+ }
263
+ }
264
+ for (const milestone of PROGRESS_MILESTONES) {
265
+ if (percent >= milestone && !view.milestones.has(milestone)) {
266
+ view.milestones.add(milestone);
267
+ if (!this.completed.has(reelId)) {
268
+ this.track({ type: "view_progress", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });
269
+ }
270
+ }
271
+ }
272
+ }
273
+ /** The video reached its end (it then loops). */
274
+ ended(reelId) {
275
+ const view = this.view(reelId);
276
+ if (view?.durationMs) this.progress(reelId, view.durationMs);
277
+ }
278
+ /** The viewer scrolled away; a return opens a new view. */
279
+ deactivate() {
280
+ this.current = null;
281
+ }
282
+ view(reelId) {
283
+ return this.current && this.current.reelId === reelId ? this.current : null;
284
+ }
285
+ };
286
+ function createViewTracker(client, options) {
287
+ return new ViewTracker((input) => {
288
+ try {
289
+ client.track(input);
290
+ } catch {
291
+ }
292
+ }, options);
293
+ }
294
+
295
+ // src/NoriFeed.tsx
296
+ var import_jsx_runtime2 = require("react/jsx-runtime");
297
+ var DEFAULT_MIN_WATCH_PERCENT = 90;
298
+ var DEFAULT_MIN_VIEW_SECONDS = 5;
299
+ function NoriFeed({
300
+ client,
301
+ tag = null,
302
+ pageSize = 10,
303
+ muted = false,
304
+ renderOverlay,
305
+ onReelChange,
306
+ renderEmpty,
307
+ renderHoldout,
308
+ height: fixedHeight,
309
+ style,
310
+ testID = "nori-feed"
311
+ }) {
312
+ const feed = useFeed(client, { tag, pageSize });
313
+ const [config, setConfig] = (0, import_react3.useState)(null);
314
+ const [measuredHeight, setHeight] = (0, import_react3.useState)(0);
315
+ const height = fixedHeight ?? measuredHeight;
316
+ const [activeIndex, setActiveIndex] = (0, import_react3.useState)(0);
317
+ (0, import_react3.useEffect)(() => {
318
+ let live = true;
319
+ client.config().then(
320
+ (c) => live && setConfig(c),
321
+ () => void 0
322
+ // theme and reward rules have defaults
323
+ );
324
+ return () => {
325
+ live = false;
326
+ };
327
+ }, [client]);
328
+ const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;
329
+ const minViewSeconds = config?.rewards.min_view_seconds ?? DEFAULT_MIN_VIEW_SECONDS;
330
+ const tracker = (0, import_react3.useMemo)(
331
+ () => createViewTracker(client, { minWatchPercent, minViewSeconds }),
332
+ [client, minWatchPercent, minViewSeconds]
333
+ );
334
+ (0, import_react3.useEffect)(() => {
335
+ const sub = import_react_native2.AppState.addEventListener("change", (state) => {
336
+ if (state !== "active") void client.flush().catch(() => void 0);
337
+ });
338
+ return () => sub.remove();
339
+ }, [client]);
340
+ const onReelChangeRef = (0, import_react3.useRef)(onReelChange);
341
+ onReelChangeRef.current = onReelChange;
342
+ const reelsRef = (0, import_react3.useRef)(feed.reels);
343
+ reelsRef.current = feed.reels;
344
+ const onViewableItemsChanged = (0, import_react3.useRef)(({ viewableItems }) => {
345
+ const first = viewableItems.find((v) => v.isViewable && v.index !== null);
346
+ if (first?.index == null) return;
347
+ setActiveIndex(first.index);
348
+ const reel = reelsRef.current[first.index];
349
+ if (reel) onReelChangeRef.current?.(reel, first.index);
350
+ }).current;
351
+ const viewabilityConfig = (0, import_react3.useRef)({ itemVisiblePercentThreshold: 80 }).current;
352
+ const onLayout = (0, import_react3.useCallback)((e) => {
353
+ const h = Math.round(e.nativeEvent.layout.height);
354
+ if (h > 0) setHeight(h);
355
+ }, []);
356
+ const theme = config?.theme ?? {};
357
+ const background = theme.background_color ?? "#000";
358
+ const textColor = theme.text_color ?? "#fff";
359
+ const accent = theme.primary_color ?? "#fff";
360
+ let body;
361
+ if (feed.loading && feed.reels.length === 0) {
362
+ body = /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.ActivityIndicator, { color: accent, style: styles2.center, testID: `${testID}-loading` });
363
+ } else if (feed.holdout) {
364
+ body = renderHoldout ? renderHoldout() : null;
365
+ } else if (feed.error && feed.reels.length === 0) {
366
+ body = /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react_native2.View, { style: styles2.center, testID: `${testID}-error`, children: [
367
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.Text, { style: [styles2.message, { color: textColor }], children: "Couldn't load the feed." }),
368
+ /* @__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" }) })
369
+ ] });
370
+ } else if (feed.reels.length === 0) {
371
+ 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." }) });
372
+ } else if (height > 0) {
373
+ body = /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
374
+ import_react_native2.FlatList,
375
+ {
376
+ data: feed.reels,
377
+ keyExtractor: (reel) => String(reel.id),
378
+ renderItem: ({ item, index }) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
379
+ ReelItem,
380
+ {
381
+ reel: item,
382
+ active: index === activeIndex,
383
+ height,
384
+ muted,
385
+ textColor,
386
+ tracker,
387
+ renderOverlay
388
+ }
389
+ ),
390
+ getItemLayout: (_, index) => ({ length: height, offset: height * index, index }),
391
+ pagingEnabled: true,
392
+ snapToInterval: height,
393
+ snapToAlignment: "start",
394
+ decelerationRate: "fast",
395
+ showsVerticalScrollIndicator: false,
396
+ onViewableItemsChanged,
397
+ viewabilityConfig,
398
+ onEndReached: () => void feed.loadMore(),
399
+ onEndReachedThreshold: 2,
400
+ refreshing: feed.refreshing,
401
+ onRefresh: () => void feed.refresh(),
402
+ windowSize: 3,
403
+ initialNumToRender: 2,
404
+ maxToRenderPerBatch: 2,
405
+ removeClippedSubviews: true,
406
+ ListFooterComponent: feed.loadingMore ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.ActivityIndicator, { color: accent, style: styles2.footer }) : void 0
407
+ }
408
+ );
409
+ }
410
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.View, { style: [styles2.container, { backgroundColor: background }, style], onLayout, testID, children: body });
411
+ }
412
+ var styles2 = import_react_native2.StyleSheet.create({
413
+ container: { flex: 1, overflow: "hidden" },
414
+ center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
415
+ message: { fontSize: 16, textAlign: "center", marginBottom: 16 },
416
+ retry: { borderWidth: 1, borderRadius: 20, paddingHorizontal: 20, paddingVertical: 10 },
417
+ retryText: { fontSize: 15, fontWeight: "600" },
418
+ footer: { paddingVertical: 24 }
419
+ });
420
+ // Annotate the CommonJS export names for ESM import in node:
421
+ 0 && (module.exports = {
422
+ NoriFeed,
423
+ ViewTracker,
424
+ createViewTracker,
425
+ useFeed
426
+ });
427
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +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;\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 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.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.\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 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","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","Video","import_react","import_jsx_runtime","styles"]}
@@ -0,0 +1,112 @@
1
+ import React from 'react';
2
+ import { StyleProp, ViewStyle } from 'react-native';
3
+ import { TrackInput, Reel, NoriClient, NoriError } from '@getnori/core';
4
+
5
+ interface ViewTrackerOptions {
6
+ /** Report view_complete once playback reaches this share of the reel (config.rewards.min_watch_percent). */
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;
12
+ }
13
+ /**
14
+ * Turns playback callbacks into the events the API expects for one reel.
15
+ *
16
+ * becomes visible impression (once per reel per feed)
17
+ * video loaded view_progress @0 (sent at once: opens the view on the server's clock)
18
+ * 25 / 50 / 75 % view_progress
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").
26
+ *
27
+ * Images get an impression only: a reward needs a watched video.
28
+ */
29
+ declare class ViewTracker {
30
+ private readonly track;
31
+ private readonly options;
32
+ private impressions;
33
+ private completed;
34
+ private current;
35
+ private readonly now;
36
+ constructor(track: (input: TrackInput) => void, options: ViewTrackerOptions);
37
+ /** The reel is now the visible one. */
38
+ activate(reel: Pick<Reel, "id" | "media_type">): void;
39
+ /** The visible video loaded and knows its duration. */
40
+ loaded(reelId: number, durationMs: number): void;
41
+ /** Playback position of the visible video (called while it plays). */
42
+ progress(reelId: number, positionMs: number): void;
43
+ /** The video reached its end (it then loops). */
44
+ ended(reelId: number): void;
45
+ /** The viewer scrolled away; a return opens a new view. */
46
+ deactivate(): void;
47
+ private view;
48
+ }
49
+ declare function createViewTracker(client: Pick<NoriClient, "track">, options: ViewTrackerOptions): ViewTracker;
50
+
51
+ interface ReelOverlayContext {
52
+ reel: Reel;
53
+ /** This reel is the one on screen. */
54
+ active: boolean;
55
+ /** Playback position (0 for images). */
56
+ positionMs: number;
57
+ durationMs: number | null;
58
+ }
59
+
60
+ interface NoriFeedProps {
61
+ client: NoriClient;
62
+ /** Only reels with this tag. */
63
+ tag?: string | null;
64
+ /** Reels per page. Default 10. */
65
+ pageSize?: number;
66
+ /** Start muted. Default false. */
67
+ muted?: boolean;
68
+ /**
69
+ * Drawn over each reel, e.g. Commerce product cards or a Rewards badge:
70
+ * `renderOverlay={(ctx) => <ProductStrip client={nori} reel={ctx.reel} positionMs={ctx.positionMs} ... />}`
71
+ */
72
+ renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;
73
+ /** The visible reel changed. */
74
+ onReelChange?: (reel: Reel, index: number) => void;
75
+ /** Shown when there are no reels. */
76
+ renderEmpty?: () => React.ReactNode;
77
+ /**
78
+ * Shown to holdout viewers (the experiment's control group, who see no
79
+ * feed). Default: nothing. Show your app's usual content here.
80
+ */
81
+ renderHoldout?: () => React.ReactNode;
82
+ /** Fixed reel height. Default: the height of the feed's own container. */
83
+ height?: number;
84
+ style?: StyleProp<ViewStyle>;
85
+ testID?: string;
86
+ }
87
+ /** A full-height, vertically paged reel feed. Fills its parent. */
88
+ declare function NoriFeed({ client, tag, pageSize, muted, renderOverlay, onReelChange, renderEmpty, renderHoldout, height: fixedHeight, style, testID, }: NoriFeedProps): React.JSX.Element;
89
+
90
+ interface UseFeedOptions {
91
+ /** Only reels with this tag. */
92
+ tag?: string | null;
93
+ /** Reels per page (1-50). Default 10. */
94
+ pageSize?: number;
95
+ }
96
+ interface FeedState {
97
+ reels: Reel[];
98
+ /** First page loading. */
99
+ loading: boolean;
100
+ loadingMore: boolean;
101
+ refreshing: boolean;
102
+ error: NoriError | Error | null;
103
+ /** This viewer is in the holdout group: the feed is intentionally empty. */
104
+ holdout: boolean;
105
+ hasMore: boolean;
106
+ loadMore: () => Promise<void>;
107
+ refresh: () => Promise<void>;
108
+ }
109
+ /** Pages through the viewer's feed. Reels already shown are never duplicated. */
110
+ declare function useFeed(client: NoriClient, { tag, pageSize }?: UseFeedOptions): FeedState;
111
+
112
+ export { type FeedState, NoriFeed, type NoriFeedProps, type ReelOverlayContext, type UseFeedOptions, ViewTracker, type ViewTrackerOptions, createViewTracker, useFeed };