@getnori/feed 0.1.0 → 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 CHANGED
@@ -99,6 +99,7 @@ function ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderO
99
99
  ),
100
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
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,
102
103
  reel.title ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: [styles.title, { color: textColor }], numberOfLines: 2, children: reel.title }) : null,
103
104
  reel.description ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: [styles.description, { color: textColor }], numberOfLines: 3, children: reel.description }) : null
104
105
  ] }),
@@ -123,7 +124,18 @@ var styles = import_react_native.StyleSheet.create({
123
124
  alignItems: "center",
124
125
  justifyContent: "center"
125
126
  },
126
- pausedText: { color: "#fff", fontSize: 26, marginLeft: 4 }
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 }
127
139
  });
128
140
 
129
141
  // src/useFeed.ts
@@ -187,6 +199,7 @@ function useFeed(client, { tag = null, pageSize = 10 } = {}) {
187
199
 
188
200
  // src/viewTracker.ts
189
201
  var PROGRESS_MILESTONES = [25, 50, 75];
202
+ var MAX_TICK_MS = 1500;
190
203
  var ViewTracker = class {
191
204
  constructor(track, options) {
192
205
  this.track = track;
@@ -194,11 +207,20 @@ var ViewTracker = class {
194
207
  this.impressions = /* @__PURE__ */ new Set();
195
208
  this.completed = /* @__PURE__ */ new Set();
196
209
  this.current = null;
210
+ this.now = options.now ?? (() => Date.now());
197
211
  }
198
212
  /** The reel is now the visible one. */
199
213
  activate(reel) {
200
214
  if (this.current?.reelId === reel.id) return;
201
- this.current = { reelId: reel.id, durationMs: null, milestones: /* @__PURE__ */ new Set(), opened: false };
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
+ };
202
224
  if (!this.impressions.has(reel.id)) {
203
225
  this.impressions.add(reel.id);
204
226
  this.track({ type: "impression", reel_id: reel.id });
@@ -211,30 +233,44 @@ var ViewTracker = class {
211
233
  view.durationMs = Math.round(durationMs);
212
234
  if (!view.opened) {
213
235
  view.opened = true;
236
+ view.lastTickAt = this.now();
214
237
  this.track({ type: "view_progress", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });
215
238
  }
216
239
  }
217
- /** Playback position of the visible video. */
240
+ /** Playback position of the visible video (called while it plays). */
218
241
  progress(reelId, positionMs) {
219
242
  const view = this.view(reelId);
220
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;
221
250
  const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));
222
- const percent = position / view.durationMs * 100;
251
+ view.furthestMs = Math.max(view.furthestMs, position);
252
+ const percent = view.furthestMs / view.durationMs * 100;
223
253
  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;
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
+ }
227
263
  }
228
264
  for (const milestone of PROGRESS_MILESTONES) {
229
265
  if (percent >= milestone && !view.milestones.has(milestone)) {
230
266
  view.milestones.add(milestone);
231
267
  if (!this.completed.has(reelId)) {
232
- this.track({ type: "view_progress", reel_id: reelId, position_ms: position, duration_ms: view.durationMs });
268
+ this.track({ type: "view_progress", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });
233
269
  }
234
270
  }
235
271
  }
236
272
  }
237
- /** The video played to the end (also covers a minWatchPercent of 100). */
273
+ /** The video reached its end (it then loops). */
238
274
  ended(reelId) {
239
275
  const view = this.view(reelId);
240
276
  if (view?.durationMs) this.progress(reelId, view.durationMs);
@@ -259,6 +295,7 @@ function createViewTracker(client, options) {
259
295
  // src/NoriFeed.tsx
260
296
  var import_jsx_runtime2 = require("react/jsx-runtime");
261
297
  var DEFAULT_MIN_WATCH_PERCENT = 90;
298
+ var DEFAULT_MIN_VIEW_SECONDS = 5;
262
299
  function NoriFeed({
263
300
  client,
264
301
  tag = null,
@@ -289,7 +326,11 @@ function NoriFeed({
289
326
  };
290
327
  }, [client]);
291
328
  const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;
292
- const tracker = (0, import_react3.useMemo)(() => createViewTracker(client, { minWatchPercent }), [client, minWatchPercent]);
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
+ );
293
334
  (0, import_react3.useEffect)(() => {
294
335
  const sub = import_react_native2.AppState.addEventListener("change", (state) => {
295
336
  if (state !== "active") void client.flush().catch(() => void 0);
@@ -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/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"]}
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;
package/dist/index.d.ts CHANGED
@@ -3,16 +3,26 @@ import { StyleProp, ViewStyle } from 'react-native';
3
3
  import { TrackInput, Reel, NoriClient, NoriError } from '@getnori/core';
4
4
 
5
5
  interface ViewTrackerOptions {
6
- /** Report view_complete once playback reaches this share of the reel (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;
package/dist/index.js CHANGED
@@ -68,6 +68,7 @@ function ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderO
68
68
  ),
69
69
  userPaused ? /* @__PURE__ */ jsx(View, { pointerEvents: "none", style: styles.pausedBadge, children: /* @__PURE__ */ jsx(Text, { style: styles.pausedText, children: "\u25B6" }) }) : null,
70
70
  /* @__PURE__ */ jsxs(View, { pointerEvents: "box-none", style: styles.caption, children: [
71
+ reel.ai_generated ? /* @__PURE__ */ jsx(View, { style: styles.aiBadge, accessibilityLabel: "AI-generated", testID: `nori-ai-label-${reel.id}`, children: /* @__PURE__ */ jsx(Text, { style: styles.aiText, children: "AI-generated" }) }) : null,
71
72
  reel.title ? /* @__PURE__ */ jsx(Text, { style: [styles.title, { color: textColor }], numberOfLines: 2, children: reel.title }) : null,
72
73
  reel.description ? /* @__PURE__ */ jsx(Text, { style: [styles.description, { color: textColor }], numberOfLines: 3, children: reel.description }) : null
73
74
  ] }),
@@ -92,7 +93,18 @@ var styles = StyleSheet.create({
92
93
  alignItems: "center",
93
94
  justifyContent: "center"
94
95
  },
95
- pausedText: { color: "#fff", fontSize: 26, marginLeft: 4 }
96
+ pausedText: { color: "#fff", fontSize: 26, marginLeft: 4 },
97
+ aiBadge: {
98
+ alignSelf: "flex-start",
99
+ marginBottom: 6,
100
+ paddingHorizontal: 8,
101
+ paddingVertical: 3,
102
+ borderRadius: 10,
103
+ backgroundColor: "rgba(0,0,0,0.55)",
104
+ borderWidth: 1,
105
+ borderColor: "rgba(255,255,255,0.35)"
106
+ },
107
+ aiText: { color: "#fff", fontSize: 11, fontWeight: "700", letterSpacing: 0.3 }
96
108
  });
97
109
 
98
110
  // src/useFeed.ts
@@ -156,6 +168,7 @@ function useFeed(client, { tag = null, pageSize = 10 } = {}) {
156
168
 
157
169
  // src/viewTracker.ts
158
170
  var PROGRESS_MILESTONES = [25, 50, 75];
171
+ var MAX_TICK_MS = 1500;
159
172
  var ViewTracker = class {
160
173
  constructor(track, options) {
161
174
  this.track = track;
@@ -163,11 +176,20 @@ var ViewTracker = class {
163
176
  this.impressions = /* @__PURE__ */ new Set();
164
177
  this.completed = /* @__PURE__ */ new Set();
165
178
  this.current = null;
179
+ this.now = options.now ?? (() => Date.now());
166
180
  }
167
181
  /** The reel is now the visible one. */
168
182
  activate(reel) {
169
183
  if (this.current?.reelId === reel.id) return;
170
- this.current = { reelId: reel.id, durationMs: null, milestones: /* @__PURE__ */ new Set(), opened: false };
184
+ this.current = {
185
+ reelId: reel.id,
186
+ durationMs: null,
187
+ milestones: /* @__PURE__ */ new Set(),
188
+ opened: false,
189
+ furthestMs: 0,
190
+ watchedMs: 0,
191
+ lastTickAt: null
192
+ };
171
193
  if (!this.impressions.has(reel.id)) {
172
194
  this.impressions.add(reel.id);
173
195
  this.track({ type: "impression", reel_id: reel.id });
@@ -180,30 +202,44 @@ var ViewTracker = class {
180
202
  view.durationMs = Math.round(durationMs);
181
203
  if (!view.opened) {
182
204
  view.opened = true;
205
+ view.lastTickAt = this.now();
183
206
  this.track({ type: "view_progress", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });
184
207
  }
185
208
  }
186
- /** Playback position of the visible video. */
209
+ /** Playback position of the visible video (called while it plays). */
187
210
  progress(reelId, positionMs) {
188
211
  const view = this.view(reelId);
189
212
  if (!view || !view.durationMs || !view.opened) return;
213
+ const now = this.now();
214
+ if (view.lastTickAt !== null) {
215
+ const gap = now - view.lastTickAt;
216
+ if (gap > 0 && gap <= MAX_TICK_MS) view.watchedMs += gap;
217
+ }
218
+ view.lastTickAt = now;
190
219
  const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));
191
- const percent = position / view.durationMs * 100;
220
+ view.furthestMs = Math.max(view.furthestMs, position);
221
+ const percent = view.furthestMs / view.durationMs * 100;
192
222
  if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {
193
- this.completed.add(reelId);
194
- this.track({ type: "view_complete", reel_id: reelId, position_ms: position, duration_ms: view.durationMs });
195
- return;
223
+ const required = Math.max(
224
+ view.durationMs * this.options.minWatchPercent / 100,
225
+ (this.options.minViewSeconds ?? 0) * 1e3
226
+ );
227
+ if (view.watchedMs >= required) {
228
+ this.completed.add(reelId);
229
+ this.track({ type: "view_complete", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });
230
+ return;
231
+ }
196
232
  }
197
233
  for (const milestone of PROGRESS_MILESTONES) {
198
234
  if (percent >= milestone && !view.milestones.has(milestone)) {
199
235
  view.milestones.add(milestone);
200
236
  if (!this.completed.has(reelId)) {
201
- this.track({ type: "view_progress", reel_id: reelId, position_ms: position, duration_ms: view.durationMs });
237
+ this.track({ type: "view_progress", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });
202
238
  }
203
239
  }
204
240
  }
205
241
  }
206
- /** The video played to the end (also covers a minWatchPercent of 100). */
242
+ /** The video reached its end (it then loops). */
207
243
  ended(reelId) {
208
244
  const view = this.view(reelId);
209
245
  if (view?.durationMs) this.progress(reelId, view.durationMs);
@@ -228,6 +264,7 @@ function createViewTracker(client, options) {
228
264
  // src/NoriFeed.tsx
229
265
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
230
266
  var DEFAULT_MIN_WATCH_PERCENT = 90;
267
+ var DEFAULT_MIN_VIEW_SECONDS = 5;
231
268
  function NoriFeed({
232
269
  client,
233
270
  tag = null,
@@ -258,7 +295,11 @@ function NoriFeed({
258
295
  };
259
296
  }, [client]);
260
297
  const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;
261
- const tracker = useMemo(() => createViewTracker(client, { minWatchPercent }), [client, minWatchPercent]);
298
+ const minViewSeconds = config?.rewards.min_view_seconds ?? DEFAULT_MIN_VIEW_SECONDS;
299
+ const tracker = useMemo(
300
+ () => createViewTracker(client, { minWatchPercent, minViewSeconds }),
301
+ [client, minWatchPercent, minViewSeconds]
302
+ );
262
303
  useEffect3(() => {
263
304
  const sub = AppState.addEventListener("change", (state) => {
264
305
  if (state !== "active") void client.flush().catch(() => void 0);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/NoriFeed.tsx","../src/ReelItem.tsx","../src/useFeed.ts","../src/viewTracker.ts"],"sourcesContent":["import React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n ActivityIndicator,\n AppState,\n FlatList,\n Pressable,\n StyleSheet,\n Text,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type FlatListProps,\n type ViewStyle,\n} from \"react-native\";\nimport type { Config, NoriClient, Reel } from \"@getnori/core\";\nimport { ReelItem, type ReelOverlayContext } from \"./ReelItem\";\nimport { useFeed } from \"./useFeed\";\nimport { createViewTracker } from \"./viewTracker\";\n\nexport interface NoriFeedProps {\n client: NoriClient;\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page. Default 10. */\n pageSize?: number;\n /** Start muted. Default false. */\n muted?: boolean;\n /**\n * Drawn over each reel, e.g. Commerce product cards or a Rewards badge:\n * `renderOverlay={(ctx) => <ProductStrip client={nori} reel={ctx.reel} positionMs={ctx.positionMs} ... />}`\n */\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** The visible reel changed. */\n onReelChange?: (reel: Reel, index: number) => void;\n /** Shown when there are no reels. */\n renderEmpty?: () => React.ReactNode;\n /**\n * Shown to holdout viewers (the experiment's control group, who see no\n * feed). Default: nothing. Show your app's usual content here.\n */\n renderHoldout?: () => React.ReactNode;\n /** Fixed reel height. Default: the height of the feed's own container. */\n height?: number;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_MIN_WATCH_PERCENT = 90;\n\n/** A full-height, vertically paged reel feed. Fills its parent. */\nexport function NoriFeed({\n client,\n tag = null,\n pageSize = 10,\n muted = false,\n renderOverlay,\n onReelChange,\n renderEmpty,\n renderHoldout,\n height: fixedHeight,\n style,\n testID = \"nori-feed\",\n}: NoriFeedProps) {\n const feed = useFeed(client, { tag, pageSize });\n const [config, setConfig] = useState<Config | null>(null);\n const [measuredHeight, setHeight] = useState(0);\n const height = fixedHeight ?? measuredHeight;\n const [activeIndex, setActiveIndex] = useState(0);\n\n useEffect(() => {\n let live = true;\n client.config().then(\n (c) => live && setConfig(c),\n () => undefined, // theme and reward rules have defaults\n );\n return () => {\n live = false;\n };\n }, [client]);\n\n const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;\n const tracker = useMemo(() => createViewTracker(client, { minWatchPercent }), [client, minWatchPercent]);\n\n // Send queued events when the app goes to the background.\n useEffect(() => {\n const sub = AppState.addEventListener(\"change\", (state) => {\n if (state !== \"active\") void client.flush().catch(() => undefined);\n });\n return () => sub.remove();\n }, [client]);\n\n const onReelChangeRef = useRef(onReelChange);\n onReelChangeRef.current = onReelChange;\n const reelsRef = useRef(feed.reels);\n reelsRef.current = feed.reels;\n\n const onViewableItemsChanged = useRef<NonNullable<FlatListProps<Reel>[\"onViewableItemsChanged\"]>>(({ viewableItems }) => {\n const first = viewableItems.find((v) => v.isViewable && v.index !== null);\n if (first?.index == null) return;\n setActiveIndex(first.index);\n const reel = reelsRef.current[first.index];\n if (reel) onReelChangeRef.current?.(reel, first.index);\n }).current;\n const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 80 }).current;\n\n const onLayout = useCallback((e: LayoutChangeEvent) => {\n const h = Math.round(e.nativeEvent.layout.height);\n if (h > 0) setHeight(h);\n }, []);\n\n const theme = config?.theme ?? {};\n const background = theme.background_color ?? \"#000\";\n const textColor = theme.text_color ?? \"#fff\";\n const accent = theme.primary_color ?? \"#fff\";\n\n let body: React.ReactNode;\n if (feed.loading && feed.reels.length === 0) {\n body = <ActivityIndicator color={accent} style={styles.center} testID={`${testID}-loading`} />;\n } else if (feed.holdout) {\n body = renderHoldout ? renderHoldout() : null;\n } else if (feed.error && feed.reels.length === 0) {\n body = (\n <View style={styles.center} testID={`${testID}-error`}>\n <Text style={[styles.message, { color: textColor }]}>Couldn't load the feed.</Text>\n <Pressable onPress={() => void feed.refresh()} accessibilityRole=\"button\" style={[styles.retry, { borderColor: accent }]}>\n <Text style={[styles.retryText, { color: accent }]}>Try again</Text>\n </Pressable>\n </View>\n );\n } else if (feed.reels.length === 0) {\n body = renderEmpty ? renderEmpty() : (\n <View style={styles.center} testID={`${testID}-empty`}>\n <Text style={[styles.message, { color: textColor }]}>Nothing here yet.</Text>\n </View>\n );\n } else if (height > 0) {\n body = (\n <FlatList\n data={feed.reels}\n keyExtractor={(reel) => String(reel.id)}\n renderItem={({ item, index }) => (\n <ReelItem\n reel={item}\n active={index === activeIndex}\n height={height}\n muted={muted}\n textColor={textColor}\n tracker={tracker}\n renderOverlay={renderOverlay}\n />\n )}\n getItemLayout={(_, index) => ({ length: height, offset: height * index, index })}\n pagingEnabled\n snapToInterval={height}\n snapToAlignment=\"start\"\n decelerationRate=\"fast\"\n showsVerticalScrollIndicator={false}\n onViewableItemsChanged={onViewableItemsChanged}\n viewabilityConfig={viewabilityConfig}\n onEndReached={() => void feed.loadMore()}\n onEndReachedThreshold={2}\n refreshing={feed.refreshing}\n onRefresh={() => void feed.refresh()}\n windowSize={3}\n initialNumToRender={2}\n maxToRenderPerBatch={2}\n removeClippedSubviews\n ListFooterComponent={feed.loadingMore ? <ActivityIndicator color={accent} style={styles.footer} /> : undefined}\n />\n );\n }\n\n return (\n <View style={[styles.container, { backgroundColor: background }, style]} onLayout={onLayout} testID={testID}>\n {body}\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n container: { flex: 1, overflow: \"hidden\" },\n center: { flex: 1, alignItems: \"center\", justifyContent: \"center\", padding: 24 },\n message: { fontSize: 16, textAlign: \"center\", marginBottom: 16 },\n retry: { borderWidth: 1, borderRadius: 20, paddingHorizontal: 20, paddingVertical: 10 },\n retryText: { fontSize: 15, fontWeight: \"600\" },\n footer: { paddingVertical: 24 },\n});\n","import React, { memo, useEffect, useRef, useState } from \"react\";\nimport { Image, Platform, Pressable, StyleSheet, Text, View } from \"react-native\";\nimport Video from \"react-native-video\";\nimport type { Reel } from \"@getnori/core\";\nimport type { ViewTracker } from \"./viewTracker\";\n\nexport interface ReelOverlayContext {\n reel: Reel;\n /** This reel is the one on screen. */\n active: boolean;\n /** Playback position (0 for images). */\n positionMs: number;\n durationMs: number | null;\n}\n\nexport interface ReelItemProps {\n reel: Reel;\n active: boolean;\n height: number;\n muted: boolean;\n textColor: string;\n tracker: ViewTracker;\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n}\n\nfunction ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay }: ReelItemProps) {\n const [userPaused, setUserPaused] = useState(false);\n const [positionMs, setPositionMs] = useState(0);\n const durationRef = useRef<number | null>(null);\n const [durationMs, setDurationMs] = useState<number | null>(null);\n const isVideo = reel.media_type === \"video\" && Boolean(reel.media_url);\n\n // Becoming visible opens a view; returning to a loaded video re-opens it.\n useEffect(() => {\n if (!active) {\n setUserPaused(false);\n return;\n }\n tracker.activate(reel);\n if (durationRef.current) tracker.loaded(reel.id, durationRef.current);\n }, [active, reel, tracker]);\n\n return (\n <View style={[styles.reel, { height }]} testID={`nori-reel-${reel.id}`}>\n <Pressable\n style={StyleSheet.absoluteFill}\n onPress={() => isVideo && setUserPaused((p) => !p)}\n accessibilityRole=\"button\"\n accessibilityLabel={userPaused ? \"Play\" : \"Pause\"}\n disabled={!isVideo}\n >\n {isVideo ? (\n <Video\n testID={`nori-video-${reel.id}`}\n source={{ uri: reel.media_url as string }}\n style={VIDEO_STYLE}\n resizeMode=\"cover\"\n repeat\n muted={muted}\n paused={!active || userPaused}\n progressUpdateInterval={250}\n playInBackground={false}\n onLoad={(data: { duration: number }) => {\n const ms = data.duration * 1000;\n durationRef.current = ms;\n setDurationMs(ms);\n if (active) tracker.loaded(reel.id, ms);\n }}\n onProgress={(data: { currentTime: number }) => {\n const ms = data.currentTime * 1000;\n setPositionMs(ms);\n if (active) tracker.progress(reel.id, ms);\n }}\n onEnd={() => active && tracker.ended(reel.id)}\n />\n ) : reel.media_url ? (\n <Image source={{ uri: reel.media_url }} style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n ) : null}\n </Pressable>\n\n {userPaused ? (\n <View pointerEvents=\"none\" style={styles.pausedBadge}>\n <Text style={styles.pausedText}>▶</Text>\n </View>\n ) : null}\n\n <View pointerEvents=\"box-none\" style={styles.caption}>\n {reel.title ? (\n <Text style={[styles.title, { color: textColor }]} numberOfLines={2}>\n {reel.title}\n </Text>\n ) : null}\n {reel.description ? (\n <Text style={[styles.description, { color: textColor }]} numberOfLines={3}>\n {reel.description}\n </Text>\n ) : null}\n </View>\n\n {renderOverlay ? (\n <View pointerEvents=\"box-none\" style={StyleSheet.absoluteFill}>\n {renderOverlay({ reel, active, positionMs, durationMs })}\n </View>\n ) : null}\n </View>\n );\n}\n\nexport const ReelItem = memo(ReelItemImpl);\n\n// react-native-video's web player ignores resizeMode and letterboxes; cover via CSS there.\nconst VIDEO_STYLE = Platform.OS === \"web\" ? [StyleSheet.absoluteFill, { objectFit: \"cover\" } as object] : StyleSheet.absoluteFill;\n\nconst styles = StyleSheet.create({\n reel: { width: \"100%\", overflow: \"hidden\", backgroundColor: \"#000\" },\n caption: { position: \"absolute\", left: 16, right: 80, bottom: 120 },\n title: { fontSize: 17, fontWeight: \"700\", marginBottom: 4, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n description: { fontSize: 14, lineHeight: 19, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n pausedBadge: {\n position: \"absolute\", alignSelf: \"center\", top: \"45%\", width: 64, height: 64, borderRadius: 32,\n backgroundColor: \"rgba(0,0,0,0.45)\", alignItems: \"center\", justifyContent: \"center\",\n },\n pausedText: { color: \"#fff\", fontSize: 26, marginLeft: 4 },\n});\n","import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { NoriClient, NoriError, Reel } from \"@getnori/core\";\n\nexport interface UseFeedOptions {\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page (1-50). Default 10. */\n pageSize?: number;\n}\n\nexport interface FeedState {\n reels: Reel[];\n /** First page loading. */\n loading: boolean;\n loadingMore: boolean;\n refreshing: boolean;\n error: NoriError | Error | null;\n /** This viewer is in the holdout group: the feed is intentionally empty. */\n holdout: boolean;\n hasMore: boolean;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n}\n\n/** Pages through the viewer's feed. Reels already shown are never duplicated. */\nexport function useFeed(client: NoriClient, { tag = null, pageSize = 10 }: UseFeedOptions = {}): FeedState {\n const [reels, setReels] = useState<Reel[]>([]);\n const [loading, setLoading] = useState(true);\n const [loadingMore, setLoadingMore] = useState(false);\n const [refreshing, setRefreshing] = useState(false);\n const [error, setError] = useState<NoriError | Error | null>(null);\n const [holdout, setHoldout] = useState(false);\n const [cursor, setCursor] = useState<string | null>(null);\n const [hasMore, setHasMore] = useState(true);\n\n const busy = useRef(false);\n const generation = useRef(0); // ignores responses from before a refresh or a tag change\n const limit = Math.min(Math.max(1, Math.round(pageSize)), 50);\n\n const load = useCallback(\n async (mode: \"first\" | \"more\" | \"refresh\", fromCursor: string | null) => {\n if (busy.current && mode === \"more\") return;\n busy.current = true;\n const gen = mode === \"more\" ? generation.current : ++generation.current;\n if (mode === \"more\") setLoadingMore(true);\n if (mode === \"refresh\") setRefreshing(true);\n try {\n const page = await client.feed({ limit, cursor: fromCursor, tag });\n if (gen !== generation.current) return;\n setError(null);\n setHoldout(Boolean(page.holdout));\n setCursor(page.next_cursor ?? null);\n setHasMore(Boolean(page.next_cursor));\n setReels((prev) => {\n const base = mode === \"more\" ? prev : [];\n const seen = new Set(base.map((r) => r.id));\n return [...base, ...page.reels.filter((r) => !seen.has(r.id))];\n });\n } catch (e) {\n if (gen === generation.current) setError(e as Error);\n } finally {\n if (gen === generation.current) {\n busy.current = false;\n setLoading(false);\n setLoadingMore(false);\n setRefreshing(false);\n }\n }\n },\n [client, limit, tag],\n );\n\n useEffect(() => {\n setLoading(true);\n setReels([]);\n void load(\"first\", null);\n }, [load]);\n\n const loadMore = useCallback(async () => {\n if (!hasMore || !cursor || loading || holdout) return;\n await load(\"more\", cursor);\n }, [cursor, hasMore, holdout, load, loading]);\n\n const refresh = useCallback(() => load(\"refresh\", null), [load]);\n\n return { reels, loading, loadingMore, refreshing, error, holdout, hasMore, loadMore, refresh };\n}\n","import type { NoriClient, Reel, TrackInput } from \"@getnori/core\";\n\nexport interface ViewTrackerOptions {\n /** Report view_complete once playback reaches this share of the reel (from config.rewards). */\n minWatchPercent: number;\n}\n\nconst PROGRESS_MILESTONES = [25, 50, 75];\n\n/**\n * Turns playback callbacks into the events the API expects for one reel.\n *\n * becomes visible impression (once per reel per feed)\n * video loaded view_progress @0 (opens the view on the server's clock)\n * 25 / 50 / 75 % view_progress\n * >= minWatchPercent view_complete (once; the server decides the reward)\n *\n * Images get an impression only: a reward needs a watched video.\n */\nexport class ViewTracker {\n private impressions = new Set<number>();\n private completed = new Set<number>();\n private current: { reelId: number; durationMs: number | null; milestones: Set<number>; opened: boolean } | null = null;\n\n constructor(\n private readonly track: (input: TrackInput) => void,\n private readonly options: ViewTrackerOptions,\n ) {}\n\n /** The reel is now the visible one. */\n activate(reel: Pick<Reel, \"id\" | \"media_type\">): void {\n if (this.current?.reelId === reel.id) return;\n this.current = { reelId: reel.id, durationMs: null, milestones: new Set(), opened: false };\n if (!this.impressions.has(reel.id)) {\n this.impressions.add(reel.id);\n this.track({ type: \"impression\", reel_id: reel.id });\n }\n }\n\n /** The visible video loaded and knows its duration. */\n loaded(reelId: number, durationMs: number): void {\n const view = this.view(reelId);\n if (!view || !(durationMs > 0)) return;\n view.durationMs = Math.round(durationMs);\n if (!view.opened) {\n view.opened = true;\n // Sent at once: the server times the view from when this arrives.\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });\n }\n }\n\n /** Playback position of the visible video. */\n progress(reelId: number, positionMs: number): void {\n const view = this.view(reelId);\n if (!view || !view.durationMs || !view.opened) return;\n const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));\n const percent = (position / view.durationMs) * 100;\n\n if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {\n this.completed.add(reelId);\n this.track({ type: \"view_complete\", reel_id: reelId, position_ms: position, duration_ms: view.durationMs });\n return;\n }\n for (const milestone of PROGRESS_MILESTONES) {\n if (percent >= milestone && !view.milestones.has(milestone)) {\n view.milestones.add(milestone);\n if (!this.completed.has(reelId)) {\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: position, duration_ms: view.durationMs });\n }\n }\n }\n }\n\n /** The video played to the end (also covers a minWatchPercent of 100). */\n ended(reelId: number): void {\n const view = this.view(reelId);\n if (view?.durationMs) this.progress(reelId, view.durationMs);\n }\n\n /** The viewer scrolled away; a return opens a new view. */\n deactivate(): void {\n this.current = null;\n }\n\n private view(reelId: number) {\n return this.current && this.current.reelId === reelId ? this.current : null;\n }\n}\n\nexport function createViewTracker(client: Pick<NoriClient, \"track\">, options: ViewTrackerOptions): ViewTracker {\n return new ViewTracker((input) => {\n try {\n client.track(input);\n } catch {\n // A disposed client must not crash playback.\n }\n }, options);\n}\n"],"mappings":"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","names":["useCallback","useEffect","useRef","useState","Pressable","StyleSheet","Text","View","useEffect","useRef","useState","jsx","jsxs","useState","useEffect","useRef","useCallback","styles","View","Text","Pressable","StyleSheet"]}
1
+ {"version":3,"sources":["../src/NoriFeed.tsx","../src/ReelItem.tsx","../src/useFeed.ts","../src/viewTracker.ts"],"sourcesContent":["import React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n ActivityIndicator,\n AppState,\n FlatList,\n Pressable,\n StyleSheet,\n Text,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type FlatListProps,\n type ViewStyle,\n} from \"react-native\";\nimport type { Config, NoriClient, Reel } from \"@getnori/core\";\nimport { ReelItem, type ReelOverlayContext } from \"./ReelItem\";\nimport { useFeed } from \"./useFeed\";\nimport { createViewTracker } from \"./viewTracker\";\n\nexport interface NoriFeedProps {\n client: NoriClient;\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page. Default 10. */\n pageSize?: number;\n /** Start muted. Default false. */\n muted?: boolean;\n /**\n * Drawn over each reel, e.g. Commerce product cards or a Rewards badge:\n * `renderOverlay={(ctx) => <ProductStrip client={nori} reel={ctx.reel} positionMs={ctx.positionMs} ... />}`\n */\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** The visible reel changed. */\n onReelChange?: (reel: Reel, index: number) => void;\n /** Shown when there are no reels. */\n renderEmpty?: () => React.ReactNode;\n /**\n * Shown to holdout viewers (the experiment's control group, who see no\n * feed). Default: nothing. Show your app's usual content here.\n */\n renderHoldout?: () => React.ReactNode;\n /** Fixed reel height. Default: the height of the feed's own container. */\n height?: number;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_MIN_WATCH_PERCENT = 90;\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":["useCallback","useEffect","useRef","useState","Pressable","StyleSheet","Text","View","useEffect","useRef","useState","jsx","jsxs","useState","useEffect","useRef","useCallback","styles","View","Text","Pressable","StyleSheet"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getnori/feed",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Nori Feed SDK: a full-screen reel panel for React Native apps, with view tracking for rewards.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@getnori/core": "^0.1.0"
35
+ "@getnori/core": "^0.2.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "react": "19.2.3",