@getnori/feed 0.3.0 → 0.3.1

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
@@ -342,6 +342,7 @@ function NoriFeed({
342
342
  renderEmpty,
343
343
  renderHoldout,
344
344
  height: fixedHeight,
345
+ paused = false,
345
346
  style,
346
347
  testID = "nori-feed"
347
348
  }) {
@@ -421,7 +422,7 @@ function NoriFeed({
421
422
  ReelItem,
422
423
  {
423
424
  reel: item,
424
- active: index === activeIndex,
425
+ active: index === activeIndex && !paused,
425
426
  height,
426
427
  muted,
427
428
  textColor,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/NoriFeed.tsx","../src/ReelItem.tsx","../src/ReelVideo.tsx","../src/videoStyle.ts","../src/useFeed.ts","../src/viewTracker.ts"],"sourcesContent":["export { NoriFeed } from \"./NoriFeed\";\nexport type { NoriFeedProps } from \"./NoriFeed\";\nexport type { ReelOverlayContext } from \"./ReelItem\";\nexport { IMAGE_REEL_SECONDS } from \"./ReelItem\";\nexport { useFeed } from \"./useFeed\";\nexport type { FeedState, UseFeedOptions } from \"./useFeed\";\nexport { ViewTracker, createViewTracker } from \"./viewTracker\";\nexport type { ViewTrackerOptions } from \"./viewTracker\";\n","import React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n ActivityIndicator,\n AppState,\n FlatList,\n Pressable,\n StyleSheet,\n Text,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type FlatListProps,\n type ViewStyle,\n} from \"react-native\";\nimport type { Config, NoriClient, Reel } from \"@getnori/core\";\nimport { ReelItem, nextFeedIndex, type ReelOverlayContext } from \"./ReelItem\";\nimport { useFeed } from \"./useFeed\";\nimport { createViewTracker } from \"./viewTracker\";\n\nexport interface NoriFeedProps {\n client: NoriClient;\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page. Default 10. */\n pageSize?: number;\n /** Start muted. Default false. */\n muted?: boolean;\n /**\n * Drawn over each reel, e.g. Commerce product cards or a Rewards badge:\n * `renderOverlay={(ctx) => <ProductStrip client={nori} reel={ctx.reel} positionMs={ctx.positionMs} ... />}`\n */\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** The visible reel changed. */\n onReelChange?: (reel: Reel, index: number) => void;\n /** Shown when there are no reels. */\n renderEmpty?: () => React.ReactNode;\n /**\n * Shown to holdout viewers (the experiment's control group, who see no\n * feed). Default: nothing. Show your app's usual content here.\n */\n renderHoldout?: () => React.ReactNode;\n /** Fixed reel height. Default: the height of the feed's own container. */\n height?: number;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_MIN_WATCH_PERCENT = 90;\nconst DEFAULT_MIN_VIEW_SECONDS = 5;\n\n/** A full-height, vertically paged reel feed. Fills its parent. */\nexport function NoriFeed({\n client,\n tag = null,\n pageSize = 10,\n muted = false,\n renderOverlay,\n onReelChange,\n renderEmpty,\n renderHoldout,\n height: fixedHeight,\n style,\n testID = \"nori-feed\",\n}: NoriFeedProps) {\n const feed = useFeed(client, { tag, pageSize });\n const [config, setConfig] = useState<Config | null>(null);\n const [measuredHeight, setHeight] = useState(0);\n const height = fixedHeight ?? measuredHeight;\n const [activeIndex, setActiveIndex] = useState(0);\n\n useEffect(() => {\n let live = true;\n client.config().then(\n (c) => live && setConfig(c),\n () => undefined, // theme and reward rules have defaults\n );\n return () => {\n live = false;\n };\n }, [client]);\n\n const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;\n const minViewSeconds = config?.rewards.min_view_seconds ?? DEFAULT_MIN_VIEW_SECONDS;\n const tracker = useMemo(\n () => createViewTracker(client, { minWatchPercent, minViewSeconds }),\n [client, minWatchPercent, minViewSeconds],\n );\n\n // Send queued events when the app goes to the background.\n useEffect(() => {\n const sub = AppState.addEventListener(\"change\", (state) => {\n if (state !== \"active\") void client.flush().catch(() => undefined);\n });\n return () => sub.remove();\n }, [client]);\n\n const onReelChangeRef = useRef(onReelChange);\n onReelChangeRef.current = onReelChange;\n const reelsRef = useRef(feed.reels);\n reelsRef.current = feed.reels;\n\n const listRef = useRef<FlatList<Reel>>(null);\n const advanceFrom = useCallback((index: number) => {\n const next = nextFeedIndex(index, reelsRef.current.length);\n if (next != null) listRef.current?.scrollToIndex({ index: next, animated: true });\n }, []);\n\n const onViewableItemsChanged = useRef<NonNullable<FlatListProps<Reel>[\"onViewableItemsChanged\"]>>(({ viewableItems }) => {\n const first = viewableItems.find((v) => v.isViewable && v.index !== null);\n if (first?.index == null) return;\n setActiveIndex(first.index);\n const reel = reelsRef.current[first.index];\n if (reel) onReelChangeRef.current?.(reel, first.index);\n }).current;\n const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 80 }).current;\n\n const onLayout = useCallback((e: LayoutChangeEvent) => {\n const h = Math.round(e.nativeEvent.layout.height);\n if (h > 0) setHeight(h);\n }, []);\n\n const theme = config?.theme ?? {};\n const background = theme.background_color ?? \"#000\";\n const textColor = theme.text_color ?? \"#fff\";\n const accent = theme.primary_color ?? \"#fff\";\n\n let body: React.ReactNode;\n if (feed.loading && feed.reels.length === 0) {\n body = <ActivityIndicator color={accent} style={styles.center} testID={`${testID}-loading`} />;\n } else if (feed.holdout) {\n body = renderHoldout ? renderHoldout() : null;\n } else if (feed.error && feed.reels.length === 0) {\n body = (\n <View style={styles.center} testID={`${testID}-error`}>\n <Text style={[styles.message, { color: textColor }]}>Couldn't load the feed.</Text>\n <Pressable onPress={() => void feed.refresh()} accessibilityRole=\"button\" style={[styles.retry, { borderColor: accent }]}>\n <Text style={[styles.retryText, { color: accent }]}>Try again</Text>\n </Pressable>\n </View>\n );\n } else if (feed.reels.length === 0) {\n body = renderEmpty ? renderEmpty() : (\n <View style={styles.center} testID={`${testID}-empty`}>\n <Text style={[styles.message, { color: textColor }]}>Nothing here yet.</Text>\n </View>\n );\n } else if (height > 0) {\n body = (\n <FlatList\n ref={listRef}\n data={feed.reels}\n keyExtractor={(reel) => String(reel.id)}\n renderItem={({ item, index }) => (\n <ReelItem\n reel={item}\n active={index === activeIndex}\n height={height}\n muted={muted}\n textColor={textColor}\n tracker={tracker}\n renderOverlay={renderOverlay}\n onImageShown={() => advanceFrom(index)}\n />\n )}\n getItemLayout={(_, index) => ({ length: height, offset: height * index, index })}\n pagingEnabled\n snapToInterval={height}\n snapToAlignment=\"start\"\n decelerationRate=\"fast\"\n showsVerticalScrollIndicator={false}\n onViewableItemsChanged={onViewableItemsChanged}\n viewabilityConfig={viewabilityConfig}\n onEndReached={() => void feed.loadMore()}\n onEndReachedThreshold={2}\n refreshing={feed.refreshing}\n onRefresh={() => void feed.refresh()}\n windowSize={3}\n initialNumToRender={2}\n maxToRenderPerBatch={2}\n removeClippedSubviews\n ListFooterComponent={feed.loadingMore ? <ActivityIndicator color={accent} style={styles.footer} /> : undefined}\n />\n );\n }\n\n return (\n <View style={[styles.container, { backgroundColor: background }, style]} onLayout={onLayout} testID={testID}>\n {body}\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n container: { flex: 1, overflow: \"hidden\" },\n center: { flex: 1, alignItems: \"center\", justifyContent: \"center\", padding: 24 },\n message: { fontSize: 16, textAlign: \"center\", marginBottom: 16 },\n retry: { borderWidth: 1, borderRadius: 20, paddingHorizontal: 20, paddingVertical: 10 },\n retryText: { fontSize: 15, fontWeight: \"600\" },\n footer: { paddingVertical: 24 },\n});\n","import React, { memo, useEffect, useRef, useState } from \"react\";\nimport { Image, Pressable, StyleSheet, Text, View } from \"react-native\";\nimport { ReelVideo } from \"./ReelVideo\";\nimport type { Reel } from \"@getnori/core\";\nimport type { ViewTracker } from \"./viewTracker\";\n\n/** How long an image reel stays on screen before the feed moves on (see #15). */\nexport const IMAGE_REEL_SECONDS = 15;\n\n/** The index to scroll to after `index`, or null at the end of the loaded reels. */\nexport function nextFeedIndex(index: number, count: number): number | null {\n return index + 1 < count ? index + 1 : null;\n}\n\nexport interface ReelOverlayContext {\n reel: Reel;\n /** This reel is the one on screen. */\n active: boolean;\n /** Playback position (0 for images). */\n positionMs: number;\n durationMs: number | null;\n}\n\nexport interface ReelItemProps {\n reel: Reel;\n active: boolean;\n height: number;\n muted: boolean;\n textColor: string;\n tracker: ViewTracker;\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** Called when an image reel has been on screen for IMAGE_REEL_SECONDS. */\n onImageShown?: () => void;\n}\n\nfunction ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay, onImageShown }: ReelItemProps) {\n const [userPaused, setUserPaused] = useState(false);\n const [positionMs, setPositionMs] = useState(0);\n const durationRef = useRef<number | null>(null);\n const [durationMs, setDurationMs] = useState<number | null>(null);\n const isVideo = reel.media_type === \"video\" && Boolean(reel.media_url);\n // Keep the latest callback in a ref so a parent re-render doesn't restart the image timer.\n const imageShownRef = useRef(onImageShown);\n imageShownRef.current = onImageShown;\n\n // An image reel stays on screen for IMAGE_REEL_SECONDS, then the feed moves on.\n useEffect(() => {\n if (!active || isVideo || !reel.media_url) return;\n const timer = setTimeout(() => imageShownRef.current?.(), IMAGE_REEL_SECONDS * 1000);\n return () => clearTimeout(timer);\n }, [active, isVideo, reel.media_url, reel.id]);\n\n // Becoming visible opens a view; returning to a loaded video re-opens it.\n useEffect(() => {\n if (!active) {\n setUserPaused(false);\n return;\n }\n tracker.activate(reel);\n if (durationRef.current) tracker.loaded(reel.id, durationRef.current);\n }, [active, reel, tracker]);\n\n return (\n <View style={[styles.reel, { height }]} testID={`nori-reel-${reel.id}`}>\n <Pressable\n style={StyleSheet.absoluteFill}\n onPress={() => isVideo && setUserPaused((p) => !p)}\n accessibilityRole=\"button\"\n accessibilityLabel={userPaused ? \"Play\" : \"Pause\"}\n disabled={!isVideo}\n >\n {isVideo ? (\n <ReelVideo\n testID={`nori-video-${reel.id}`}\n uri={reel.media_url as string}\n muted={muted}\n paused={!active || userPaused}\n onLoad={(seconds) => {\n const ms = seconds * 1000;\n durationRef.current = ms;\n setDurationMs(ms);\n if (active) tracker.loaded(reel.id, ms);\n }}\n onProgress={(seconds) => {\n const ms = seconds * 1000;\n setPositionMs(ms);\n if (active) tracker.progress(reel.id, ms);\n }}\n onEnd={() => active && tracker.ended(reel.id)}\n />\n ) : reel.media_url ? (\n <Image source={{ uri: reel.media_url }} style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n ) : null}\n </Pressable>\n\n {userPaused ? (\n <View pointerEvents=\"none\" style={styles.pausedBadge}>\n <Text style={styles.pausedText}>▶</Text>\n </View>\n ) : null}\n\n <View pointerEvents=\"box-none\" style={styles.caption}>\n {reel.ai_generated ? (\n <View style={styles.aiBadge} accessibilityLabel=\"AI-generated\" testID={`nori-ai-label-${reel.id}`}>\n <Text style={styles.aiText}>AI-generated</Text>\n </View>\n ) : null}\n {reel.title ? (\n <Text style={[styles.title, { color: textColor }]} numberOfLines={2}>\n {reel.title}\n </Text>\n ) : null}\n {reel.description ? (\n <Text style={[styles.description, { color: textColor }]} numberOfLines={3}>\n {reel.description}\n </Text>\n ) : null}\n </View>\n\n {renderOverlay ? (\n <View pointerEvents=\"box-none\" style={StyleSheet.absoluteFill}>\n {renderOverlay({ reel, active, positionMs, durationMs })}\n </View>\n ) : null}\n </View>\n );\n}\n\nexport const ReelItem = memo(ReelItemImpl);\n\n// react-native-video's web player ignores resizeMode and letterboxes; cover via CSS there.\n\nconst styles = StyleSheet.create({\n reel: { width: \"100%\", overflow: \"hidden\", backgroundColor: \"#000\" },\n caption: { position: \"absolute\", left: 16, right: 80, bottom: 120 },\n title: { fontSize: 17, fontWeight: \"700\", marginBottom: 4, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n description: { fontSize: 14, lineHeight: 19, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n pausedBadge: {\n position: \"absolute\", alignSelf: \"center\", top: \"45%\", width: 64, height: 64, borderRadius: 32,\n backgroundColor: \"rgba(0,0,0,0.45)\", alignItems: \"center\", justifyContent: \"center\",\n },\n pausedText: { color: \"#fff\", fontSize: 26, marginLeft: 4 },\n aiBadge: {\n alignSelf: \"flex-start\", marginBottom: 6, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10,\n backgroundColor: \"rgba(0,0,0,0.55)\", borderWidth: 1, borderColor: \"rgba(255,255,255,0.35)\",\n },\n aiText: { color: \"#fff\", fontSize: 11, fontWeight: \"700\", letterSpacing: 0.3 },\n});\n","/** Native reel video (iOS, Android): react-native-video. The web build uses ReelVideo.web.tsx. */\nimport React from \"react\";\nimport Video from \"react-native-video\";\nimport { VIDEO_STYLE } from \"./videoStyle\";\n\nexport interface ReelVideoProps {\n testID?: string;\n uri: string;\n muted: boolean;\n paused: boolean;\n /** Duration in seconds, once known. */\n onLoad: (durationSeconds: number) => void;\n /** Playback position in seconds. */\n onProgress: (positionSeconds: number) => void;\n onEnd: () => void;\n}\n\nexport function ReelVideo({ testID, uri, muted, paused, onLoad, onProgress, onEnd }: ReelVideoProps) {\n return (\n <Video\n testID={testID}\n source={{ uri }}\n style={VIDEO_STYLE}\n resizeMode=\"cover\"\n repeat\n muted={muted}\n paused={paused}\n progressUpdateInterval={250}\n playInBackground={false}\n onLoad={(data: { duration: number }) => onLoad(data.duration)}\n onProgress={(data: { currentTime: number }) => onProgress(data.currentTime)}\n onEnd={onEnd}\n />\n );\n}\n","import { Platform, StyleSheet } from \"react-native\";\n\n/** Full-bleed video. On the web the browser's object-fit does the cover fit. */\nexport const VIDEO_STYLE =\n Platform.OS === \"web\" ? [StyleSheet.absoluteFill, { objectFit: \"cover\" } as object] : StyleSheet.absoluteFill;\n","import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { NoriClient, NoriError, Reel } from \"@getnori/core\";\n\nexport interface UseFeedOptions {\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page (1-50). Default 10. */\n pageSize?: number;\n}\n\nexport interface FeedState {\n reels: Reel[];\n /** First page loading. */\n loading: boolean;\n loadingMore: boolean;\n refreshing: boolean;\n error: NoriError | Error | null;\n /** This viewer is in the holdout group: the feed is intentionally empty. */\n holdout: boolean;\n hasMore: boolean;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n}\n\n/** Pages through the viewer's feed. Reels already shown are never duplicated. */\nexport function useFeed(client: NoriClient, { tag = null, pageSize = 10 }: UseFeedOptions = {}): FeedState {\n const [reels, setReels] = useState<Reel[]>([]);\n const [loading, setLoading] = useState(true);\n const [loadingMore, setLoadingMore] = useState(false);\n const [refreshing, setRefreshing] = useState(false);\n const [error, setError] = useState<NoriError | Error | null>(null);\n const [holdout, setHoldout] = useState(false);\n const [cursor, setCursor] = useState<string | null>(null);\n const [hasMore, setHasMore] = useState(true);\n\n const busy = useRef(false);\n const generation = useRef(0); // ignores responses from before a refresh or a tag change\n const limit = Math.min(Math.max(1, Math.round(pageSize)), 50);\n\n const load = useCallback(\n async (mode: \"first\" | \"more\" | \"refresh\", fromCursor: string | null) => {\n if (busy.current && mode === \"more\") return;\n busy.current = true;\n const gen = mode === \"more\" ? generation.current : ++generation.current;\n if (mode === \"more\") setLoadingMore(true);\n if (mode === \"refresh\") setRefreshing(true);\n try {\n const page = await client.feed({ limit, cursor: fromCursor, tag });\n if (gen !== generation.current) return;\n setError(null);\n setHoldout(Boolean(page.holdout));\n setCursor(page.next_cursor ?? null);\n setHasMore(Boolean(page.next_cursor));\n setReels((prev) => {\n const base = mode === \"more\" ? prev : [];\n const seen = new Set(base.map((r) => r.id));\n return [...base, ...page.reels.filter((r) => !seen.has(r.id))];\n });\n } catch (e) {\n if (gen === generation.current) setError(e as Error);\n } finally {\n if (gen === generation.current) {\n busy.current = false;\n setLoading(false);\n setLoadingMore(false);\n setRefreshing(false);\n }\n }\n },\n [client, limit, tag],\n );\n\n useEffect(() => {\n setLoading(true);\n setReels([]);\n void load(\"first\", null);\n }, [load]);\n\n const loadMore = useCallback(async () => {\n if (!hasMore || !cursor || loading || holdout) return;\n await load(\"more\", cursor);\n }, [cursor, hasMore, holdout, load, loading]);\n\n const refresh = useCallback(() => load(\"refresh\", null), [load]);\n\n return { reels, loading, loadingMore, refreshing, error, holdout, hasMore, loadMore, refresh };\n}\n","import type { NoriClient, Reel, TrackInput } from \"@getnori/core\";\n\nexport interface ViewTrackerOptions {\n /** Report view_complete once playback reaches this share of the reel (config.rewards.min_watch_percent). */\n minWatchPercent: number;\n /** ...and the viewer has watched at least this long, counting loops (config.rewards.min_view_seconds). */\n minViewSeconds?: number;\n /** Clock in ms (tests). Default Date.now. */\n now?: () => number;\n}\n\nconst PROGRESS_MILESTONES = [25, 50, 75];\n/** A gap between progress callbacks longer than this (paused, backgrounded) doesn't count as watching. */\nconst MAX_TICK_MS = 1500;\n\ninterface View {\n reelId: number;\n durationMs: number | null;\n milestones: Set<number>;\n opened: boolean;\n furthestMs: number;\n watchedMs: number;\n lastTickAt: number | null;\n}\n\n/**\n * Turns playback callbacks into the events the API expects for one reel.\n *\n * becomes visible impression (once per reel per feed)\n * video loaded view_progress @0 (sent at once: opens the view on the server's clock)\n * 25 / 50 / 75 % view_progress\n * complete view_complete (once; the server decides the reward)\n *\n * \"Complete\" needs both conditions the server checks: the furthest position\n * reaches minWatchPercent of the reel, and the viewer has watched for at\n * least max(that share of the reel, minViewSeconds), counting loops. So a\n * 5-second reel with a 5-second minimum completes after one full play, not\n * at 80 % (which the server would refuse as \"watched too fast\").\n *\n * Images get an impression only: a reward needs a watched video.\n */\nexport class ViewTracker {\n private impressions = new Set<number>();\n private completed = new Set<number>();\n private current: View | null = null;\n private readonly now: () => number;\n\n constructor(\n private readonly track: (input: TrackInput) => void,\n private readonly options: ViewTrackerOptions,\n ) {\n this.now = options.now ?? (() => Date.now());\n }\n\n /** The reel is now the visible one. */\n activate(reel: Pick<Reel, \"id\" | \"media_type\">): void {\n if (this.current?.reelId === reel.id) return;\n this.current = {\n reelId: reel.id, durationMs: null, milestones: new Set(), opened: false,\n furthestMs: 0, watchedMs: 0, lastTickAt: null,\n };\n if (!this.impressions.has(reel.id)) {\n this.impressions.add(reel.id);\n this.track({ type: \"impression\", reel_id: reel.id });\n }\n }\n\n /** The visible video loaded and knows its duration. */\n loaded(reelId: number, durationMs: number): void {\n const view = this.view(reelId);\n if (!view || !(durationMs > 0)) return;\n view.durationMs = Math.round(durationMs);\n if (!view.opened) {\n view.opened = true;\n view.lastTickAt = this.now();\n // Sent at once: the server times the view from when this arrives.\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });\n }\n }\n\n /** Playback position of the visible video (called while it plays). */\n progress(reelId: number, positionMs: number): void {\n const view = this.view(reelId);\n if (!view || !view.durationMs || !view.opened) return;\n const now = this.now();\n if (view.lastTickAt !== null) {\n const gap = now - view.lastTickAt;\n if (gap > 0 && gap <= MAX_TICK_MS) view.watchedMs += gap;\n }\n view.lastTickAt = now;\n const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));\n view.furthestMs = Math.max(view.furthestMs, position);\n const percent = (view.furthestMs / view.durationMs) * 100;\n\n if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {\n const required = Math.max(\n (view.durationMs * this.options.minWatchPercent) / 100,\n (this.options.minViewSeconds ?? 0) * 1000,\n );\n if (view.watchedMs >= required) {\n this.completed.add(reelId);\n this.track({ type: \"view_complete\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n return;\n }\n }\n for (const milestone of PROGRESS_MILESTONES) {\n if (percent >= milestone && !view.milestones.has(milestone)) {\n view.milestones.add(milestone);\n if (!this.completed.has(reelId)) {\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n }\n }\n }\n }\n\n /** The video reached its end (it then loops). */\n ended(reelId: number): void {\n const view = this.view(reelId);\n if (view?.durationMs) this.progress(reelId, view.durationMs);\n }\n\n /** The viewer scrolled away; a return opens a new view. */\n deactivate(): void {\n this.current = null;\n }\n\n private view(reelId: number) {\n return this.current && this.current.reelId === reelId ? this.current : null;\n }\n}\n\nexport function createViewTracker(client: Pick<NoriClient, \"track\">, options: ViewTrackerOptions): ViewTracker {\n return new ViewTracker((input) => {\n try {\n client.track(input);\n } catch {\n // A disposed client must not crash playback.\n }\n }, options);\n}\n"],"mappings":"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","names":["import_react","import_react_native","import_react_native","Video","import_jsx_runtime","import_react","import_jsx_runtime","styles"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/NoriFeed.tsx","../src/ReelItem.tsx","../src/ReelVideo.tsx","../src/videoStyle.ts","../src/useFeed.ts","../src/viewTracker.ts"],"sourcesContent":["export { NoriFeed } from \"./NoriFeed\";\nexport type { NoriFeedProps } from \"./NoriFeed\";\nexport type { ReelOverlayContext } from \"./ReelItem\";\nexport { IMAGE_REEL_SECONDS } from \"./ReelItem\";\nexport { useFeed } from \"./useFeed\";\nexport type { FeedState, UseFeedOptions } from \"./useFeed\";\nexport { ViewTracker, createViewTracker } from \"./viewTracker\";\nexport type { ViewTrackerOptions } from \"./viewTracker\";\n","import React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n ActivityIndicator,\n AppState,\n FlatList,\n Pressable,\n StyleSheet,\n Text,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type FlatListProps,\n type ViewStyle,\n} from \"react-native\";\nimport type { Config, NoriClient, Reel } from \"@getnori/core\";\nimport { ReelItem, nextFeedIndex, type ReelOverlayContext } from \"./ReelItem\";\nimport { useFeed } from \"./useFeed\";\nimport { createViewTracker } from \"./viewTracker\";\n\nexport interface NoriFeedProps {\n client: NoriClient;\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page. Default 10. */\n pageSize?: number;\n /** Start muted. Default false. */\n muted?: boolean;\n /**\n * Drawn over each reel, e.g. Commerce product cards or a Rewards badge:\n * `renderOverlay={(ctx) => <ProductStrip client={nori} reel={ctx.reel} positionMs={ctx.positionMs} ... />}`\n */\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** The visible reel changed. */\n onReelChange?: (reel: Reel, index: number) => void;\n /** Shown when there are no reels. */\n renderEmpty?: () => React.ReactNode;\n /**\n * Shown to holdout viewers (the experiment's control group, who see no\n * feed). Default: nothing. Show your app's usual content here.\n */\n renderHoldout?: () => React.ReactNode;\n /**\n * Stop playback and the image timer, e.g. while the feed's tab is in the background.\n * Pass `!isFocused` from React Navigation. Default: false.\n */\n paused?: boolean;\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 paused = false,\n style,\n testID = \"nori-feed\",\n}: NoriFeedProps) {\n const feed = useFeed(client, { tag, pageSize });\n const [config, setConfig] = useState<Config | null>(null);\n const [measuredHeight, setHeight] = useState(0);\n const height = fixedHeight ?? measuredHeight;\n const [activeIndex, setActiveIndex] = useState(0);\n\n useEffect(() => {\n let live = true;\n client.config().then(\n (c) => live && setConfig(c),\n () => undefined, // theme and reward rules have defaults\n );\n return () => {\n live = false;\n };\n }, [client]);\n\n const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;\n const minViewSeconds = config?.rewards.min_view_seconds ?? DEFAULT_MIN_VIEW_SECONDS;\n const tracker = useMemo(\n () => createViewTracker(client, { minWatchPercent, minViewSeconds }),\n [client, minWatchPercent, minViewSeconds],\n );\n\n // Send queued events when the app goes to the background.\n useEffect(() => {\n const sub = AppState.addEventListener(\"change\", (state) => {\n if (state !== \"active\") void client.flush().catch(() => undefined);\n });\n return () => sub.remove();\n }, [client]);\n\n const onReelChangeRef = useRef(onReelChange);\n onReelChangeRef.current = onReelChange;\n const reelsRef = useRef(feed.reels);\n reelsRef.current = feed.reels;\n\n const listRef = useRef<FlatList<Reel>>(null);\n const advanceFrom = useCallback((index: number) => {\n const next = nextFeedIndex(index, reelsRef.current.length);\n if (next != null) listRef.current?.scrollToIndex({ index: next, animated: true });\n }, []);\n\n const onViewableItemsChanged = useRef<NonNullable<FlatListProps<Reel>[\"onViewableItemsChanged\"]>>(({ viewableItems }) => {\n const first = viewableItems.find((v) => v.isViewable && v.index !== null);\n if (first?.index == null) return;\n setActiveIndex(first.index);\n const reel = reelsRef.current[first.index];\n if (reel) onReelChangeRef.current?.(reel, first.index);\n }).current;\n const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 80 }).current;\n\n const onLayout = useCallback((e: LayoutChangeEvent) => {\n const h = Math.round(e.nativeEvent.layout.height);\n if (h > 0) setHeight(h);\n }, []);\n\n const theme = config?.theme ?? {};\n const background = theme.background_color ?? \"#000\";\n const textColor = theme.text_color ?? \"#fff\";\n const accent = theme.primary_color ?? \"#fff\";\n\n let body: React.ReactNode;\n if (feed.loading && feed.reels.length === 0) {\n body = <ActivityIndicator color={accent} style={styles.center} testID={`${testID}-loading`} />;\n } else if (feed.holdout) {\n body = renderHoldout ? renderHoldout() : null;\n } else if (feed.error && feed.reels.length === 0) {\n body = (\n <View style={styles.center} testID={`${testID}-error`}>\n <Text style={[styles.message, { color: textColor }]}>Couldn't load the feed.</Text>\n <Pressable onPress={() => void feed.refresh()} accessibilityRole=\"button\" style={[styles.retry, { borderColor: accent }]}>\n <Text style={[styles.retryText, { color: accent }]}>Try again</Text>\n </Pressable>\n </View>\n );\n } else if (feed.reels.length === 0) {\n body = renderEmpty ? renderEmpty() : (\n <View style={styles.center} testID={`${testID}-empty`}>\n <Text style={[styles.message, { color: textColor }]}>Nothing here yet.</Text>\n </View>\n );\n } else if (height > 0) {\n body = (\n <FlatList\n ref={listRef}\n data={feed.reels}\n keyExtractor={(reel) => String(reel.id)}\n renderItem={({ item, index }) => (\n <ReelItem\n reel={item}\n active={index === activeIndex && !paused}\n height={height}\n muted={muted}\n textColor={textColor}\n tracker={tracker}\n renderOverlay={renderOverlay}\n onImageShown={() => advanceFrom(index)}\n />\n )}\n getItemLayout={(_, index) => ({ length: height, offset: height * index, index })}\n pagingEnabled\n snapToInterval={height}\n snapToAlignment=\"start\"\n decelerationRate=\"fast\"\n showsVerticalScrollIndicator={false}\n onViewableItemsChanged={onViewableItemsChanged}\n viewabilityConfig={viewabilityConfig}\n onEndReached={() => void feed.loadMore()}\n onEndReachedThreshold={2}\n refreshing={feed.refreshing}\n onRefresh={() => void feed.refresh()}\n windowSize={3}\n initialNumToRender={2}\n maxToRenderPerBatch={2}\n removeClippedSubviews\n ListFooterComponent={feed.loadingMore ? <ActivityIndicator color={accent} style={styles.footer} /> : undefined}\n />\n );\n }\n\n return (\n <View style={[styles.container, { backgroundColor: background }, style]} onLayout={onLayout} testID={testID}>\n {body}\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n container: { flex: 1, overflow: \"hidden\" },\n center: { flex: 1, alignItems: \"center\", justifyContent: \"center\", padding: 24 },\n message: { fontSize: 16, textAlign: \"center\", marginBottom: 16 },\n retry: { borderWidth: 1, borderRadius: 20, paddingHorizontal: 20, paddingVertical: 10 },\n retryText: { fontSize: 15, fontWeight: \"600\" },\n footer: { paddingVertical: 24 },\n});\n","import React, { memo, useEffect, useRef, useState } from \"react\";\nimport { Image, Pressable, StyleSheet, Text, View } from \"react-native\";\nimport { ReelVideo } from \"./ReelVideo\";\nimport type { Reel } from \"@getnori/core\";\nimport type { ViewTracker } from \"./viewTracker\";\n\n/** How long an image reel stays on screen before the feed moves on (see #15). */\nexport const IMAGE_REEL_SECONDS = 15;\n\n/** The index to scroll to after `index`, or null at the end of the loaded reels. */\nexport function nextFeedIndex(index: number, count: number): number | null {\n return index + 1 < count ? index + 1 : null;\n}\n\nexport interface ReelOverlayContext {\n reel: Reel;\n /** This reel is the one on screen. */\n active: boolean;\n /** Playback position (0 for images). */\n positionMs: number;\n durationMs: number | null;\n}\n\nexport interface ReelItemProps {\n reel: Reel;\n active: boolean;\n height: number;\n muted: boolean;\n textColor: string;\n tracker: ViewTracker;\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** Called when an image reel has been on screen for IMAGE_REEL_SECONDS. */\n onImageShown?: () => void;\n}\n\nfunction ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay, onImageShown }: ReelItemProps) {\n const [userPaused, setUserPaused] = useState(false);\n const [positionMs, setPositionMs] = useState(0);\n const durationRef = useRef<number | null>(null);\n const [durationMs, setDurationMs] = useState<number | null>(null);\n const isVideo = reel.media_type === \"video\" && Boolean(reel.media_url);\n // Keep the latest callback in a ref so a parent re-render doesn't restart the image timer.\n const imageShownRef = useRef(onImageShown);\n imageShownRef.current = onImageShown;\n\n // An image reel stays on screen for IMAGE_REEL_SECONDS, then the feed moves on.\n useEffect(() => {\n if (!active || isVideo || !reel.media_url) return;\n const timer = setTimeout(() => imageShownRef.current?.(), IMAGE_REEL_SECONDS * 1000);\n return () => clearTimeout(timer);\n }, [active, isVideo, reel.media_url, reel.id]);\n\n // Becoming visible opens a view; returning to a loaded video re-opens it.\n useEffect(() => {\n if (!active) {\n setUserPaused(false);\n return;\n }\n tracker.activate(reel);\n if (durationRef.current) tracker.loaded(reel.id, durationRef.current);\n }, [active, reel, tracker]);\n\n return (\n <View style={[styles.reel, { height }]} testID={`nori-reel-${reel.id}`}>\n <Pressable\n style={StyleSheet.absoluteFill}\n onPress={() => isVideo && setUserPaused((p) => !p)}\n accessibilityRole=\"button\"\n accessibilityLabel={userPaused ? \"Play\" : \"Pause\"}\n disabled={!isVideo}\n >\n {isVideo ? (\n <ReelVideo\n testID={`nori-video-${reel.id}`}\n uri={reel.media_url as string}\n muted={muted}\n paused={!active || userPaused}\n onLoad={(seconds) => {\n const ms = seconds * 1000;\n durationRef.current = ms;\n setDurationMs(ms);\n if (active) tracker.loaded(reel.id, ms);\n }}\n onProgress={(seconds) => {\n const ms = seconds * 1000;\n setPositionMs(ms);\n if (active) tracker.progress(reel.id, ms);\n }}\n onEnd={() => active && tracker.ended(reel.id)}\n />\n ) : reel.media_url ? (\n <Image source={{ uri: reel.media_url }} style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n ) : null}\n </Pressable>\n\n {userPaused ? (\n <View pointerEvents=\"none\" style={styles.pausedBadge}>\n <Text style={styles.pausedText}>▶</Text>\n </View>\n ) : null}\n\n <View pointerEvents=\"box-none\" style={styles.caption}>\n {reel.ai_generated ? (\n <View style={styles.aiBadge} accessibilityLabel=\"AI-generated\" testID={`nori-ai-label-${reel.id}`}>\n <Text style={styles.aiText}>AI-generated</Text>\n </View>\n ) : null}\n {reel.title ? (\n <Text style={[styles.title, { color: textColor }]} numberOfLines={2}>\n {reel.title}\n </Text>\n ) : null}\n {reel.description ? (\n <Text style={[styles.description, { color: textColor }]} numberOfLines={3}>\n {reel.description}\n </Text>\n ) : null}\n </View>\n\n {renderOverlay ? (\n <View pointerEvents=\"box-none\" style={StyleSheet.absoluteFill}>\n {renderOverlay({ reel, active, positionMs, durationMs })}\n </View>\n ) : null}\n </View>\n );\n}\n\nexport const ReelItem = memo(ReelItemImpl);\n\n// react-native-video's web player ignores resizeMode and letterboxes; cover via CSS there.\n\nconst styles = StyleSheet.create({\n reel: { width: \"100%\", overflow: \"hidden\", backgroundColor: \"#000\" },\n caption: { position: \"absolute\", left: 16, right: 80, bottom: 120 },\n title: { fontSize: 17, fontWeight: \"700\", marginBottom: 4, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n description: { fontSize: 14, lineHeight: 19, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n pausedBadge: {\n position: \"absolute\", alignSelf: \"center\", top: \"45%\", width: 64, height: 64, borderRadius: 32,\n backgroundColor: \"rgba(0,0,0,0.45)\", alignItems: \"center\", justifyContent: \"center\",\n },\n pausedText: { color: \"#fff\", fontSize: 26, marginLeft: 4 },\n aiBadge: {\n alignSelf: \"flex-start\", marginBottom: 6, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10,\n backgroundColor: \"rgba(0,0,0,0.55)\", borderWidth: 1, borderColor: \"rgba(255,255,255,0.35)\",\n },\n aiText: { color: \"#fff\", fontSize: 11, fontWeight: \"700\", letterSpacing: 0.3 },\n});\n","/** Native reel video (iOS, Android): react-native-video. The web build uses ReelVideo.web.tsx. */\nimport React from \"react\";\nimport Video from \"react-native-video\";\nimport { VIDEO_STYLE } from \"./videoStyle\";\n\nexport interface ReelVideoProps {\n testID?: string;\n uri: string;\n muted: boolean;\n paused: boolean;\n /** Duration in seconds, once known. */\n onLoad: (durationSeconds: number) => void;\n /** Playback position in seconds. */\n onProgress: (positionSeconds: number) => void;\n onEnd: () => void;\n}\n\nexport function ReelVideo({ testID, uri, muted, paused, onLoad, onProgress, onEnd }: ReelVideoProps) {\n return (\n <Video\n testID={testID}\n source={{ uri }}\n style={VIDEO_STYLE}\n resizeMode=\"cover\"\n repeat\n muted={muted}\n paused={paused}\n progressUpdateInterval={250}\n playInBackground={false}\n onLoad={(data: { duration: number }) => onLoad(data.duration)}\n onProgress={(data: { currentTime: number }) => onProgress(data.currentTime)}\n onEnd={onEnd}\n />\n );\n}\n","import { Platform, StyleSheet } from \"react-native\";\n\n/** Full-bleed video. On the web the browser's object-fit does the cover fit. */\nexport const VIDEO_STYLE =\n Platform.OS === \"web\" ? [StyleSheet.absoluteFill, { objectFit: \"cover\" } as object] : StyleSheet.absoluteFill;\n","import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { NoriClient, NoriError, Reel } from \"@getnori/core\";\n\nexport interface UseFeedOptions {\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page (1-50). Default 10. */\n pageSize?: number;\n}\n\nexport interface FeedState {\n reels: Reel[];\n /** First page loading. */\n loading: boolean;\n loadingMore: boolean;\n refreshing: boolean;\n error: NoriError | Error | null;\n /** This viewer is in the holdout group: the feed is intentionally empty. */\n holdout: boolean;\n hasMore: boolean;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n}\n\n/** Pages through the viewer's feed. Reels already shown are never duplicated. */\nexport function useFeed(client: NoriClient, { tag = null, pageSize = 10 }: UseFeedOptions = {}): FeedState {\n const [reels, setReels] = useState<Reel[]>([]);\n const [loading, setLoading] = useState(true);\n const [loadingMore, setLoadingMore] = useState(false);\n const [refreshing, setRefreshing] = useState(false);\n const [error, setError] = useState<NoriError | Error | null>(null);\n const [holdout, setHoldout] = useState(false);\n const [cursor, setCursor] = useState<string | null>(null);\n const [hasMore, setHasMore] = useState(true);\n\n const busy = useRef(false);\n const generation = useRef(0); // ignores responses from before a refresh or a tag change\n const limit = Math.min(Math.max(1, Math.round(pageSize)), 50);\n\n const load = useCallback(\n async (mode: \"first\" | \"more\" | \"refresh\", fromCursor: string | null) => {\n if (busy.current && mode === \"more\") return;\n busy.current = true;\n const gen = mode === \"more\" ? generation.current : ++generation.current;\n if (mode === \"more\") setLoadingMore(true);\n if (mode === \"refresh\") setRefreshing(true);\n try {\n const page = await client.feed({ limit, cursor: fromCursor, tag });\n if (gen !== generation.current) return;\n setError(null);\n setHoldout(Boolean(page.holdout));\n setCursor(page.next_cursor ?? null);\n setHasMore(Boolean(page.next_cursor));\n setReels((prev) => {\n const base = mode === \"more\" ? prev : [];\n const seen = new Set(base.map((r) => r.id));\n return [...base, ...page.reels.filter((r) => !seen.has(r.id))];\n });\n } catch (e) {\n if (gen === generation.current) setError(e as Error);\n } finally {\n if (gen === generation.current) {\n busy.current = false;\n setLoading(false);\n setLoadingMore(false);\n setRefreshing(false);\n }\n }\n },\n [client, limit, tag],\n );\n\n useEffect(() => {\n setLoading(true);\n setReels([]);\n void load(\"first\", null);\n }, [load]);\n\n const loadMore = useCallback(async () => {\n if (!hasMore || !cursor || loading || holdout) return;\n await load(\"more\", cursor);\n }, [cursor, hasMore, holdout, load, loading]);\n\n const refresh = useCallback(() => load(\"refresh\", null), [load]);\n\n return { reels, loading, loadingMore, refreshing, error, holdout, hasMore, loadMore, refresh };\n}\n","import type { NoriClient, Reel, TrackInput } from \"@getnori/core\";\n\nexport interface ViewTrackerOptions {\n /** Report view_complete once playback reaches this share of the reel (config.rewards.min_watch_percent). */\n minWatchPercent: number;\n /** ...and the viewer has watched at least this long, counting loops (config.rewards.min_view_seconds). */\n minViewSeconds?: number;\n /** Clock in ms (tests). Default Date.now. */\n now?: () => number;\n}\n\nconst PROGRESS_MILESTONES = [25, 50, 75];\n/** A gap between progress callbacks longer than this (paused, backgrounded) doesn't count as watching. */\nconst MAX_TICK_MS = 1500;\n\ninterface View {\n reelId: number;\n durationMs: number | null;\n milestones: Set<number>;\n opened: boolean;\n furthestMs: number;\n watchedMs: number;\n lastTickAt: number | null;\n}\n\n/**\n * Turns playback callbacks into the events the API expects for one reel.\n *\n * becomes visible impression (once per reel per feed)\n * video loaded view_progress @0 (sent at once: opens the view on the server's clock)\n * 25 / 50 / 75 % view_progress\n * complete view_complete (once; the server decides the reward)\n *\n * \"Complete\" needs both conditions the server checks: the furthest position\n * reaches minWatchPercent of the reel, and the viewer has watched for at\n * least max(that share of the reel, minViewSeconds), counting loops. So a\n * 5-second reel with a 5-second minimum completes after one full play, not\n * at 80 % (which the server would refuse as \"watched too fast\").\n *\n * Images get an impression only: a reward needs a watched video.\n */\nexport class ViewTracker {\n private impressions = new Set<number>();\n private completed = new Set<number>();\n private current: View | null = null;\n private readonly now: () => number;\n\n constructor(\n private readonly track: (input: TrackInput) => void,\n private readonly options: ViewTrackerOptions,\n ) {\n this.now = options.now ?? (() => Date.now());\n }\n\n /** The reel is now the visible one. */\n activate(reel: Pick<Reel, \"id\" | \"media_type\">): void {\n if (this.current?.reelId === reel.id) return;\n this.current = {\n reelId: reel.id, durationMs: null, milestones: new Set(), opened: false,\n furthestMs: 0, watchedMs: 0, lastTickAt: null,\n };\n if (!this.impressions.has(reel.id)) {\n this.impressions.add(reel.id);\n this.track({ type: \"impression\", reel_id: reel.id });\n }\n }\n\n /** The visible video loaded and knows its duration. */\n loaded(reelId: number, durationMs: number): void {\n const view = this.view(reelId);\n if (!view || !(durationMs > 0)) return;\n view.durationMs = Math.round(durationMs);\n if (!view.opened) {\n view.opened = true;\n view.lastTickAt = this.now();\n // Sent at once: the server times the view from when this arrives.\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });\n }\n }\n\n /** Playback position of the visible video (called while it plays). */\n progress(reelId: number, positionMs: number): void {\n const view = this.view(reelId);\n if (!view || !view.durationMs || !view.opened) return;\n const now = this.now();\n if (view.lastTickAt !== null) {\n const gap = now - view.lastTickAt;\n if (gap > 0 && gap <= MAX_TICK_MS) view.watchedMs += gap;\n }\n view.lastTickAt = now;\n const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));\n view.furthestMs = Math.max(view.furthestMs, position);\n const percent = (view.furthestMs / view.durationMs) * 100;\n\n if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {\n const required = Math.max(\n (view.durationMs * this.options.minWatchPercent) / 100,\n (this.options.minViewSeconds ?? 0) * 1000,\n );\n if (view.watchedMs >= required) {\n this.completed.add(reelId);\n this.track({ type: \"view_complete\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n return;\n }\n }\n for (const milestone of PROGRESS_MILESTONES) {\n if (percent >= milestone && !view.milestones.has(milestone)) {\n view.milestones.add(milestone);\n if (!this.completed.has(reelId)) {\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n }\n }\n }\n }\n\n /** The video reached its end (it then loops). */\n ended(reelId: number): void {\n const view = this.view(reelId);\n if (view?.durationMs) this.progress(reelId, view.durationMs);\n }\n\n /** The viewer scrolled away; a return opens a new view. */\n deactivate(): void {\n this.current = null;\n }\n\n private view(reelId: number) {\n return this.current && this.current.reelId === reelId ? this.current : null;\n }\n}\n\nexport function createViewTracker(client: Pick<NoriClient, \"track\">, options: ViewTrackerOptions): ViewTracker {\n return new ViewTracker((input) => {\n try {\n client.track(input);\n } catch {\n // A disposed client must not crash playback.\n }\n }, options);\n}\n"],"mappings":"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","names":["import_react","import_react_native","import_react_native","Video","import_jsx_runtime","import_react","import_jsx_runtime","styles"]}
package/dist/index.d.cts CHANGED
@@ -81,13 +81,18 @@ interface NoriFeedProps {
81
81
  * feed). Default: nothing. Show your app's usual content here.
82
82
  */
83
83
  renderHoldout?: () => React.ReactNode;
84
+ /**
85
+ * Stop playback and the image timer, e.g. while the feed's tab is in the background.
86
+ * Pass `!isFocused` from React Navigation. Default: false.
87
+ */
88
+ paused?: boolean;
84
89
  /** Fixed reel height. Default: the height of the feed's own container. */
85
90
  height?: number;
86
91
  style?: StyleProp<ViewStyle>;
87
92
  testID?: string;
88
93
  }
89
94
  /** A full-height, vertically paged reel feed. Fills its parent. */
90
- declare function NoriFeed({ client, tag, pageSize, muted, renderOverlay, onReelChange, renderEmpty, renderHoldout, height: fixedHeight, style, testID, }: NoriFeedProps): React.JSX.Element;
95
+ declare function NoriFeed({ client, tag, pageSize, muted, renderOverlay, onReelChange, renderEmpty, renderHoldout, height: fixedHeight, paused, style, testID, }: NoriFeedProps): React.JSX.Element;
91
96
 
92
97
  interface UseFeedOptions {
93
98
  /** Only reels with this tag. */
package/dist/index.d.ts CHANGED
@@ -81,13 +81,18 @@ interface NoriFeedProps {
81
81
  * feed). Default: nothing. Show your app's usual content here.
82
82
  */
83
83
  renderHoldout?: () => React.ReactNode;
84
+ /**
85
+ * Stop playback and the image timer, e.g. while the feed's tab is in the background.
86
+ * Pass `!isFocused` from React Navigation. Default: false.
87
+ */
88
+ paused?: boolean;
84
89
  /** Fixed reel height. Default: the height of the feed's own container. */
85
90
  height?: number;
86
91
  style?: StyleProp<ViewStyle>;
87
92
  testID?: string;
88
93
  }
89
94
  /** A full-height, vertically paged reel feed. Fills its parent. */
90
- declare function NoriFeed({ client, tag, pageSize, muted, renderOverlay, onReelChange, renderEmpty, renderHoldout, height: fixedHeight, style, testID, }: NoriFeedProps): React.JSX.Element;
95
+ declare function NoriFeed({ client, tag, pageSize, muted, renderOverlay, onReelChange, renderEmpty, renderHoldout, height: fixedHeight, paused, style, testID, }: NoriFeedProps): React.JSX.Element;
91
96
 
92
97
  interface UseFeedOptions {
93
98
  /** Only reels with this tag. */
package/dist/index.js CHANGED
@@ -310,6 +310,7 @@ function NoriFeed({
310
310
  renderEmpty,
311
311
  renderHoldout,
312
312
  height: fixedHeight,
313
+ paused = false,
313
314
  style,
314
315
  testID = "nori-feed"
315
316
  }) {
@@ -389,7 +390,7 @@ function NoriFeed({
389
390
  ReelItem,
390
391
  {
391
392
  reel: item,
392
- active: index === activeIndex,
393
+ active: index === activeIndex && !paused,
393
394
  height,
394
395
  muted,
395
396
  textColor,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/NoriFeed.tsx","../src/ReelItem.tsx","../src/ReelVideo.tsx","../src/videoStyle.ts","../src/useFeed.ts","../src/viewTracker.ts"],"sourcesContent":["import React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n ActivityIndicator,\n AppState,\n FlatList,\n Pressable,\n StyleSheet,\n Text,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type FlatListProps,\n type ViewStyle,\n} from \"react-native\";\nimport type { Config, NoriClient, Reel } from \"@getnori/core\";\nimport { ReelItem, nextFeedIndex, type ReelOverlayContext } from \"./ReelItem\";\nimport { useFeed } from \"./useFeed\";\nimport { createViewTracker } from \"./viewTracker\";\n\nexport interface NoriFeedProps {\n client: NoriClient;\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page. Default 10. */\n pageSize?: number;\n /** Start muted. Default false. */\n muted?: boolean;\n /**\n * Drawn over each reel, e.g. Commerce product cards or a Rewards badge:\n * `renderOverlay={(ctx) => <ProductStrip client={nori} reel={ctx.reel} positionMs={ctx.positionMs} ... />}`\n */\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** The visible reel changed. */\n onReelChange?: (reel: Reel, index: number) => void;\n /** Shown when there are no reels. */\n renderEmpty?: () => React.ReactNode;\n /**\n * Shown to holdout viewers (the experiment's control group, who see no\n * feed). Default: nothing. Show your app's usual content here.\n */\n renderHoldout?: () => React.ReactNode;\n /** Fixed reel height. Default: the height of the feed's own container. */\n height?: number;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_MIN_WATCH_PERCENT = 90;\nconst DEFAULT_MIN_VIEW_SECONDS = 5;\n\n/** A full-height, vertically paged reel feed. Fills its parent. */\nexport function NoriFeed({\n client,\n tag = null,\n pageSize = 10,\n muted = false,\n renderOverlay,\n onReelChange,\n renderEmpty,\n renderHoldout,\n height: fixedHeight,\n style,\n testID = \"nori-feed\",\n}: NoriFeedProps) {\n const feed = useFeed(client, { tag, pageSize });\n const [config, setConfig] = useState<Config | null>(null);\n const [measuredHeight, setHeight] = useState(0);\n const height = fixedHeight ?? measuredHeight;\n const [activeIndex, setActiveIndex] = useState(0);\n\n useEffect(() => {\n let live = true;\n client.config().then(\n (c) => live && setConfig(c),\n () => undefined, // theme and reward rules have defaults\n );\n return () => {\n live = false;\n };\n }, [client]);\n\n const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;\n const minViewSeconds = config?.rewards.min_view_seconds ?? DEFAULT_MIN_VIEW_SECONDS;\n const tracker = useMemo(\n () => createViewTracker(client, { minWatchPercent, minViewSeconds }),\n [client, minWatchPercent, minViewSeconds],\n );\n\n // Send queued events when the app goes to the background.\n useEffect(() => {\n const sub = AppState.addEventListener(\"change\", (state) => {\n if (state !== \"active\") void client.flush().catch(() => undefined);\n });\n return () => sub.remove();\n }, [client]);\n\n const onReelChangeRef = useRef(onReelChange);\n onReelChangeRef.current = onReelChange;\n const reelsRef = useRef(feed.reels);\n reelsRef.current = feed.reels;\n\n const listRef = useRef<FlatList<Reel>>(null);\n const advanceFrom = useCallback((index: number) => {\n const next = nextFeedIndex(index, reelsRef.current.length);\n if (next != null) listRef.current?.scrollToIndex({ index: next, animated: true });\n }, []);\n\n const onViewableItemsChanged = useRef<NonNullable<FlatListProps<Reel>[\"onViewableItemsChanged\"]>>(({ viewableItems }) => {\n const first = viewableItems.find((v) => v.isViewable && v.index !== null);\n if (first?.index == null) return;\n setActiveIndex(first.index);\n const reel = reelsRef.current[first.index];\n if (reel) onReelChangeRef.current?.(reel, first.index);\n }).current;\n const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 80 }).current;\n\n const onLayout = useCallback((e: LayoutChangeEvent) => {\n const h = Math.round(e.nativeEvent.layout.height);\n if (h > 0) setHeight(h);\n }, []);\n\n const theme = config?.theme ?? {};\n const background = theme.background_color ?? \"#000\";\n const textColor = theme.text_color ?? \"#fff\";\n const accent = theme.primary_color ?? \"#fff\";\n\n let body: React.ReactNode;\n if (feed.loading && feed.reels.length === 0) {\n body = <ActivityIndicator color={accent} style={styles.center} testID={`${testID}-loading`} />;\n } else if (feed.holdout) {\n body = renderHoldout ? renderHoldout() : null;\n } else if (feed.error && feed.reels.length === 0) {\n body = (\n <View style={styles.center} testID={`${testID}-error`}>\n <Text style={[styles.message, { color: textColor }]}>Couldn't load the feed.</Text>\n <Pressable onPress={() => void feed.refresh()} accessibilityRole=\"button\" style={[styles.retry, { borderColor: accent }]}>\n <Text style={[styles.retryText, { color: accent }]}>Try again</Text>\n </Pressable>\n </View>\n );\n } else if (feed.reels.length === 0) {\n body = renderEmpty ? renderEmpty() : (\n <View style={styles.center} testID={`${testID}-empty`}>\n <Text style={[styles.message, { color: textColor }]}>Nothing here yet.</Text>\n </View>\n );\n } else if (height > 0) {\n body = (\n <FlatList\n ref={listRef}\n data={feed.reels}\n keyExtractor={(reel) => String(reel.id)}\n renderItem={({ item, index }) => (\n <ReelItem\n reel={item}\n active={index === activeIndex}\n height={height}\n muted={muted}\n textColor={textColor}\n tracker={tracker}\n renderOverlay={renderOverlay}\n onImageShown={() => advanceFrom(index)}\n />\n )}\n getItemLayout={(_, index) => ({ length: height, offset: height * index, index })}\n pagingEnabled\n snapToInterval={height}\n snapToAlignment=\"start\"\n decelerationRate=\"fast\"\n showsVerticalScrollIndicator={false}\n onViewableItemsChanged={onViewableItemsChanged}\n viewabilityConfig={viewabilityConfig}\n onEndReached={() => void feed.loadMore()}\n onEndReachedThreshold={2}\n refreshing={feed.refreshing}\n onRefresh={() => void feed.refresh()}\n windowSize={3}\n initialNumToRender={2}\n maxToRenderPerBatch={2}\n removeClippedSubviews\n ListFooterComponent={feed.loadingMore ? <ActivityIndicator color={accent} style={styles.footer} /> : undefined}\n />\n );\n }\n\n return (\n <View style={[styles.container, { backgroundColor: background }, style]} onLayout={onLayout} testID={testID}>\n {body}\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n container: { flex: 1, overflow: \"hidden\" },\n center: { flex: 1, alignItems: \"center\", justifyContent: \"center\", padding: 24 },\n message: { fontSize: 16, textAlign: \"center\", marginBottom: 16 },\n retry: { borderWidth: 1, borderRadius: 20, paddingHorizontal: 20, paddingVertical: 10 },\n retryText: { fontSize: 15, fontWeight: \"600\" },\n footer: { paddingVertical: 24 },\n});\n","import React, { memo, useEffect, useRef, useState } from \"react\";\nimport { Image, Pressable, StyleSheet, Text, View } from \"react-native\";\nimport { ReelVideo } from \"./ReelVideo\";\nimport type { Reel } from \"@getnori/core\";\nimport type { ViewTracker } from \"./viewTracker\";\n\n/** How long an image reel stays on screen before the feed moves on (see #15). */\nexport const IMAGE_REEL_SECONDS = 15;\n\n/** The index to scroll to after `index`, or null at the end of the loaded reels. */\nexport function nextFeedIndex(index: number, count: number): number | null {\n return index + 1 < count ? index + 1 : null;\n}\n\nexport interface ReelOverlayContext {\n reel: Reel;\n /** This reel is the one on screen. */\n active: boolean;\n /** Playback position (0 for images). */\n positionMs: number;\n durationMs: number | null;\n}\n\nexport interface ReelItemProps {\n reel: Reel;\n active: boolean;\n height: number;\n muted: boolean;\n textColor: string;\n tracker: ViewTracker;\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** Called when an image reel has been on screen for IMAGE_REEL_SECONDS. */\n onImageShown?: () => void;\n}\n\nfunction ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay, onImageShown }: ReelItemProps) {\n const [userPaused, setUserPaused] = useState(false);\n const [positionMs, setPositionMs] = useState(0);\n const durationRef = useRef<number | null>(null);\n const [durationMs, setDurationMs] = useState<number | null>(null);\n const isVideo = reel.media_type === \"video\" && Boolean(reel.media_url);\n // Keep the latest callback in a ref so a parent re-render doesn't restart the image timer.\n const imageShownRef = useRef(onImageShown);\n imageShownRef.current = onImageShown;\n\n // An image reel stays on screen for IMAGE_REEL_SECONDS, then the feed moves on.\n useEffect(() => {\n if (!active || isVideo || !reel.media_url) return;\n const timer = setTimeout(() => imageShownRef.current?.(), IMAGE_REEL_SECONDS * 1000);\n return () => clearTimeout(timer);\n }, [active, isVideo, reel.media_url, reel.id]);\n\n // Becoming visible opens a view; returning to a loaded video re-opens it.\n useEffect(() => {\n if (!active) {\n setUserPaused(false);\n return;\n }\n tracker.activate(reel);\n if (durationRef.current) tracker.loaded(reel.id, durationRef.current);\n }, [active, reel, tracker]);\n\n return (\n <View style={[styles.reel, { height }]} testID={`nori-reel-${reel.id}`}>\n <Pressable\n style={StyleSheet.absoluteFill}\n onPress={() => isVideo && setUserPaused((p) => !p)}\n accessibilityRole=\"button\"\n accessibilityLabel={userPaused ? \"Play\" : \"Pause\"}\n disabled={!isVideo}\n >\n {isVideo ? (\n <ReelVideo\n testID={`nori-video-${reel.id}`}\n uri={reel.media_url as string}\n muted={muted}\n paused={!active || userPaused}\n onLoad={(seconds) => {\n const ms = seconds * 1000;\n durationRef.current = ms;\n setDurationMs(ms);\n if (active) tracker.loaded(reel.id, ms);\n }}\n onProgress={(seconds) => {\n const ms = seconds * 1000;\n setPositionMs(ms);\n if (active) tracker.progress(reel.id, ms);\n }}\n onEnd={() => active && tracker.ended(reel.id)}\n />\n ) : reel.media_url ? (\n <Image source={{ uri: reel.media_url }} style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n ) : null}\n </Pressable>\n\n {userPaused ? (\n <View pointerEvents=\"none\" style={styles.pausedBadge}>\n <Text style={styles.pausedText}>▶</Text>\n </View>\n ) : null}\n\n <View pointerEvents=\"box-none\" style={styles.caption}>\n {reel.ai_generated ? (\n <View style={styles.aiBadge} accessibilityLabel=\"AI-generated\" testID={`nori-ai-label-${reel.id}`}>\n <Text style={styles.aiText}>AI-generated</Text>\n </View>\n ) : null}\n {reel.title ? (\n <Text style={[styles.title, { color: textColor }]} numberOfLines={2}>\n {reel.title}\n </Text>\n ) : null}\n {reel.description ? (\n <Text style={[styles.description, { color: textColor }]} numberOfLines={3}>\n {reel.description}\n </Text>\n ) : null}\n </View>\n\n {renderOverlay ? (\n <View pointerEvents=\"box-none\" style={StyleSheet.absoluteFill}>\n {renderOverlay({ reel, active, positionMs, durationMs })}\n </View>\n ) : null}\n </View>\n );\n}\n\nexport const ReelItem = memo(ReelItemImpl);\n\n// react-native-video's web player ignores resizeMode and letterboxes; cover via CSS there.\n\nconst styles = StyleSheet.create({\n reel: { width: \"100%\", overflow: \"hidden\", backgroundColor: \"#000\" },\n caption: { position: \"absolute\", left: 16, right: 80, bottom: 120 },\n title: { fontSize: 17, fontWeight: \"700\", marginBottom: 4, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n description: { fontSize: 14, lineHeight: 19, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n pausedBadge: {\n position: \"absolute\", alignSelf: \"center\", top: \"45%\", width: 64, height: 64, borderRadius: 32,\n backgroundColor: \"rgba(0,0,0,0.45)\", alignItems: \"center\", justifyContent: \"center\",\n },\n pausedText: { color: \"#fff\", fontSize: 26, marginLeft: 4 },\n aiBadge: {\n alignSelf: \"flex-start\", marginBottom: 6, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10,\n backgroundColor: \"rgba(0,0,0,0.55)\", borderWidth: 1, borderColor: \"rgba(255,255,255,0.35)\",\n },\n aiText: { color: \"#fff\", fontSize: 11, fontWeight: \"700\", letterSpacing: 0.3 },\n});\n","/** Native reel video (iOS, Android): react-native-video. The web build uses ReelVideo.web.tsx. */\nimport React from \"react\";\nimport Video from \"react-native-video\";\nimport { VIDEO_STYLE } from \"./videoStyle\";\n\nexport interface ReelVideoProps {\n testID?: string;\n uri: string;\n muted: boolean;\n paused: boolean;\n /** Duration in seconds, once known. */\n onLoad: (durationSeconds: number) => void;\n /** Playback position in seconds. */\n onProgress: (positionSeconds: number) => void;\n onEnd: () => void;\n}\n\nexport function ReelVideo({ testID, uri, muted, paused, onLoad, onProgress, onEnd }: ReelVideoProps) {\n return (\n <Video\n testID={testID}\n source={{ uri }}\n style={VIDEO_STYLE}\n resizeMode=\"cover\"\n repeat\n muted={muted}\n paused={paused}\n progressUpdateInterval={250}\n playInBackground={false}\n onLoad={(data: { duration: number }) => onLoad(data.duration)}\n onProgress={(data: { currentTime: number }) => onProgress(data.currentTime)}\n onEnd={onEnd}\n />\n );\n}\n","import { Platform, StyleSheet } from \"react-native\";\n\n/** Full-bleed video. On the web the browser's object-fit does the cover fit. */\nexport const VIDEO_STYLE =\n Platform.OS === \"web\" ? [StyleSheet.absoluteFill, { objectFit: \"cover\" } as object] : StyleSheet.absoluteFill;\n","import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { NoriClient, NoriError, Reel } from \"@getnori/core\";\n\nexport interface UseFeedOptions {\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page (1-50). Default 10. */\n pageSize?: number;\n}\n\nexport interface FeedState {\n reels: Reel[];\n /** First page loading. */\n loading: boolean;\n loadingMore: boolean;\n refreshing: boolean;\n error: NoriError | Error | null;\n /** This viewer is in the holdout group: the feed is intentionally empty. */\n holdout: boolean;\n hasMore: boolean;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n}\n\n/** Pages through the viewer's feed. Reels already shown are never duplicated. */\nexport function useFeed(client: NoriClient, { tag = null, pageSize = 10 }: UseFeedOptions = {}): FeedState {\n const [reels, setReels] = useState<Reel[]>([]);\n const [loading, setLoading] = useState(true);\n const [loadingMore, setLoadingMore] = useState(false);\n const [refreshing, setRefreshing] = useState(false);\n const [error, setError] = useState<NoriError | Error | null>(null);\n const [holdout, setHoldout] = useState(false);\n const [cursor, setCursor] = useState<string | null>(null);\n const [hasMore, setHasMore] = useState(true);\n\n const busy = useRef(false);\n const generation = useRef(0); // ignores responses from before a refresh or a tag change\n const limit = Math.min(Math.max(1, Math.round(pageSize)), 50);\n\n const load = useCallback(\n async (mode: \"first\" | \"more\" | \"refresh\", fromCursor: string | null) => {\n if (busy.current && mode === \"more\") return;\n busy.current = true;\n const gen = mode === \"more\" ? generation.current : ++generation.current;\n if (mode === \"more\") setLoadingMore(true);\n if (mode === \"refresh\") setRefreshing(true);\n try {\n const page = await client.feed({ limit, cursor: fromCursor, tag });\n if (gen !== generation.current) return;\n setError(null);\n setHoldout(Boolean(page.holdout));\n setCursor(page.next_cursor ?? null);\n setHasMore(Boolean(page.next_cursor));\n setReels((prev) => {\n const base = mode === \"more\" ? prev : [];\n const seen = new Set(base.map((r) => r.id));\n return [...base, ...page.reels.filter((r) => !seen.has(r.id))];\n });\n } catch (e) {\n if (gen === generation.current) setError(e as Error);\n } finally {\n if (gen === generation.current) {\n busy.current = false;\n setLoading(false);\n setLoadingMore(false);\n setRefreshing(false);\n }\n }\n },\n [client, limit, tag],\n );\n\n useEffect(() => {\n setLoading(true);\n setReels([]);\n void load(\"first\", null);\n }, [load]);\n\n const loadMore = useCallback(async () => {\n if (!hasMore || !cursor || loading || holdout) return;\n await load(\"more\", cursor);\n }, [cursor, hasMore, holdout, load, loading]);\n\n const refresh = useCallback(() => load(\"refresh\", null), [load]);\n\n return { reels, loading, loadingMore, refreshing, error, holdout, hasMore, loadMore, refresh };\n}\n","import type { NoriClient, Reel, TrackInput } from \"@getnori/core\";\n\nexport interface ViewTrackerOptions {\n /** Report view_complete once playback reaches this share of the reel (config.rewards.min_watch_percent). */\n minWatchPercent: number;\n /** ...and the viewer has watched at least this long, counting loops (config.rewards.min_view_seconds). */\n minViewSeconds?: number;\n /** Clock in ms (tests). Default Date.now. */\n now?: () => number;\n}\n\nconst PROGRESS_MILESTONES = [25, 50, 75];\n/** A gap between progress callbacks longer than this (paused, backgrounded) doesn't count as watching. */\nconst MAX_TICK_MS = 1500;\n\ninterface View {\n reelId: number;\n durationMs: number | null;\n milestones: Set<number>;\n opened: boolean;\n furthestMs: number;\n watchedMs: number;\n lastTickAt: number | null;\n}\n\n/**\n * Turns playback callbacks into the events the API expects for one reel.\n *\n * becomes visible impression (once per reel per feed)\n * video loaded view_progress @0 (sent at once: opens the view on the server's clock)\n * 25 / 50 / 75 % view_progress\n * complete view_complete (once; the server decides the reward)\n *\n * \"Complete\" needs both conditions the server checks: the furthest position\n * reaches minWatchPercent of the reel, and the viewer has watched for at\n * least max(that share of the reel, minViewSeconds), counting loops. So a\n * 5-second reel with a 5-second minimum completes after one full play, not\n * at 80 % (which the server would refuse as \"watched too fast\").\n *\n * Images get an impression only: a reward needs a watched video.\n */\nexport class ViewTracker {\n private impressions = new Set<number>();\n private completed = new Set<number>();\n private current: View | null = null;\n private readonly now: () => number;\n\n constructor(\n private readonly track: (input: TrackInput) => void,\n private readonly options: ViewTrackerOptions,\n ) {\n this.now = options.now ?? (() => Date.now());\n }\n\n /** The reel is now the visible one. */\n activate(reel: Pick<Reel, \"id\" | \"media_type\">): void {\n if (this.current?.reelId === reel.id) return;\n this.current = {\n reelId: reel.id, durationMs: null, milestones: new Set(), opened: false,\n furthestMs: 0, watchedMs: 0, lastTickAt: null,\n };\n if (!this.impressions.has(reel.id)) {\n this.impressions.add(reel.id);\n this.track({ type: \"impression\", reel_id: reel.id });\n }\n }\n\n /** The visible video loaded and knows its duration. */\n loaded(reelId: number, durationMs: number): void {\n const view = this.view(reelId);\n if (!view || !(durationMs > 0)) return;\n view.durationMs = Math.round(durationMs);\n if (!view.opened) {\n view.opened = true;\n view.lastTickAt = this.now();\n // Sent at once: the server times the view from when this arrives.\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });\n }\n }\n\n /** Playback position of the visible video (called while it plays). */\n progress(reelId: number, positionMs: number): void {\n const view = this.view(reelId);\n if (!view || !view.durationMs || !view.opened) return;\n const now = this.now();\n if (view.lastTickAt !== null) {\n const gap = now - view.lastTickAt;\n if (gap > 0 && gap <= MAX_TICK_MS) view.watchedMs += gap;\n }\n view.lastTickAt = now;\n const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));\n view.furthestMs = Math.max(view.furthestMs, position);\n const percent = (view.furthestMs / view.durationMs) * 100;\n\n if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {\n const required = Math.max(\n (view.durationMs * this.options.minWatchPercent) / 100,\n (this.options.minViewSeconds ?? 0) * 1000,\n );\n if (view.watchedMs >= required) {\n this.completed.add(reelId);\n this.track({ type: \"view_complete\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n return;\n }\n }\n for (const milestone of PROGRESS_MILESTONES) {\n if (percent >= milestone && !view.milestones.has(milestone)) {\n view.milestones.add(milestone);\n if (!this.completed.has(reelId)) {\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n }\n }\n }\n }\n\n /** The video reached its end (it then loops). */\n ended(reelId: number): void {\n const view = this.view(reelId);\n if (view?.durationMs) this.progress(reelId, view.durationMs);\n }\n\n /** The viewer scrolled away; a return opens a new view. */\n deactivate(): void {\n this.current = null;\n }\n\n private view(reelId: number) {\n return this.current && this.current.reelId === reelId ? this.current : null;\n }\n}\n\nexport function createViewTracker(client: Pick<NoriClient, \"track\">, options: ViewTrackerOptions): ViewTracker {\n return new ViewTracker((input) => {\n try {\n client.track(input);\n } catch {\n // A disposed client must not crash playback.\n }\n }, options);\n}\n"],"mappings":"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","names":["useCallback","useEffect","useRef","useState","Pressable","StyleSheet","Text","View","StyleSheet","jsx","StyleSheet","useEffect","useRef","useState","jsx","jsxs","useState","useEffect","useRef","useCallback","styles","View","Text","Pressable","StyleSheet"]}
1
+ {"version":3,"sources":["../src/NoriFeed.tsx","../src/ReelItem.tsx","../src/ReelVideo.tsx","../src/videoStyle.ts","../src/useFeed.ts","../src/viewTracker.ts"],"sourcesContent":["import React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n ActivityIndicator,\n AppState,\n FlatList,\n Pressable,\n StyleSheet,\n Text,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type FlatListProps,\n type ViewStyle,\n} from \"react-native\";\nimport type { Config, NoriClient, Reel } from \"@getnori/core\";\nimport { ReelItem, nextFeedIndex, type ReelOverlayContext } from \"./ReelItem\";\nimport { useFeed } from \"./useFeed\";\nimport { createViewTracker } from \"./viewTracker\";\n\nexport interface NoriFeedProps {\n client: NoriClient;\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page. Default 10. */\n pageSize?: number;\n /** Start muted. Default false. */\n muted?: boolean;\n /**\n * Drawn over each reel, e.g. Commerce product cards or a Rewards badge:\n * `renderOverlay={(ctx) => <ProductStrip client={nori} reel={ctx.reel} positionMs={ctx.positionMs} ... />}`\n */\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** The visible reel changed. */\n onReelChange?: (reel: Reel, index: number) => void;\n /** Shown when there are no reels. */\n renderEmpty?: () => React.ReactNode;\n /**\n * Shown to holdout viewers (the experiment's control group, who see no\n * feed). Default: nothing. Show your app's usual content here.\n */\n renderHoldout?: () => React.ReactNode;\n /**\n * Stop playback and the image timer, e.g. while the feed's tab is in the background.\n * Pass `!isFocused` from React Navigation. Default: false.\n */\n paused?: boolean;\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 paused = false,\n style,\n testID = \"nori-feed\",\n}: NoriFeedProps) {\n const feed = useFeed(client, { tag, pageSize });\n const [config, setConfig] = useState<Config | null>(null);\n const [measuredHeight, setHeight] = useState(0);\n const height = fixedHeight ?? measuredHeight;\n const [activeIndex, setActiveIndex] = useState(0);\n\n useEffect(() => {\n let live = true;\n client.config().then(\n (c) => live && setConfig(c),\n () => undefined, // theme and reward rules have defaults\n );\n return () => {\n live = false;\n };\n }, [client]);\n\n const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;\n const minViewSeconds = config?.rewards.min_view_seconds ?? DEFAULT_MIN_VIEW_SECONDS;\n const tracker = useMemo(\n () => createViewTracker(client, { minWatchPercent, minViewSeconds }),\n [client, minWatchPercent, minViewSeconds],\n );\n\n // Send queued events when the app goes to the background.\n useEffect(() => {\n const sub = AppState.addEventListener(\"change\", (state) => {\n if (state !== \"active\") void client.flush().catch(() => undefined);\n });\n return () => sub.remove();\n }, [client]);\n\n const onReelChangeRef = useRef(onReelChange);\n onReelChangeRef.current = onReelChange;\n const reelsRef = useRef(feed.reels);\n reelsRef.current = feed.reels;\n\n const listRef = useRef<FlatList<Reel>>(null);\n const advanceFrom = useCallback((index: number) => {\n const next = nextFeedIndex(index, reelsRef.current.length);\n if (next != null) listRef.current?.scrollToIndex({ index: next, animated: true });\n }, []);\n\n const onViewableItemsChanged = useRef<NonNullable<FlatListProps<Reel>[\"onViewableItemsChanged\"]>>(({ viewableItems }) => {\n const first = viewableItems.find((v) => v.isViewable && v.index !== null);\n if (first?.index == null) return;\n setActiveIndex(first.index);\n const reel = reelsRef.current[first.index];\n if (reel) onReelChangeRef.current?.(reel, first.index);\n }).current;\n const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 80 }).current;\n\n const onLayout = useCallback((e: LayoutChangeEvent) => {\n const h = Math.round(e.nativeEvent.layout.height);\n if (h > 0) setHeight(h);\n }, []);\n\n const theme = config?.theme ?? {};\n const background = theme.background_color ?? \"#000\";\n const textColor = theme.text_color ?? \"#fff\";\n const accent = theme.primary_color ?? \"#fff\";\n\n let body: React.ReactNode;\n if (feed.loading && feed.reels.length === 0) {\n body = <ActivityIndicator color={accent} style={styles.center} testID={`${testID}-loading`} />;\n } else if (feed.holdout) {\n body = renderHoldout ? renderHoldout() : null;\n } else if (feed.error && feed.reels.length === 0) {\n body = (\n <View style={styles.center} testID={`${testID}-error`}>\n <Text style={[styles.message, { color: textColor }]}>Couldn't load the feed.</Text>\n <Pressable onPress={() => void feed.refresh()} accessibilityRole=\"button\" style={[styles.retry, { borderColor: accent }]}>\n <Text style={[styles.retryText, { color: accent }]}>Try again</Text>\n </Pressable>\n </View>\n );\n } else if (feed.reels.length === 0) {\n body = renderEmpty ? renderEmpty() : (\n <View style={styles.center} testID={`${testID}-empty`}>\n <Text style={[styles.message, { color: textColor }]}>Nothing here yet.</Text>\n </View>\n );\n } else if (height > 0) {\n body = (\n <FlatList\n ref={listRef}\n data={feed.reels}\n keyExtractor={(reel) => String(reel.id)}\n renderItem={({ item, index }) => (\n <ReelItem\n reel={item}\n active={index === activeIndex && !paused}\n height={height}\n muted={muted}\n textColor={textColor}\n tracker={tracker}\n renderOverlay={renderOverlay}\n onImageShown={() => advanceFrom(index)}\n />\n )}\n getItemLayout={(_, index) => ({ length: height, offset: height * index, index })}\n pagingEnabled\n snapToInterval={height}\n snapToAlignment=\"start\"\n decelerationRate=\"fast\"\n showsVerticalScrollIndicator={false}\n onViewableItemsChanged={onViewableItemsChanged}\n viewabilityConfig={viewabilityConfig}\n onEndReached={() => void feed.loadMore()}\n onEndReachedThreshold={2}\n refreshing={feed.refreshing}\n onRefresh={() => void feed.refresh()}\n windowSize={3}\n initialNumToRender={2}\n maxToRenderPerBatch={2}\n removeClippedSubviews\n ListFooterComponent={feed.loadingMore ? <ActivityIndicator color={accent} style={styles.footer} /> : undefined}\n />\n );\n }\n\n return (\n <View style={[styles.container, { backgroundColor: background }, style]} onLayout={onLayout} testID={testID}>\n {body}\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n container: { flex: 1, overflow: \"hidden\" },\n center: { flex: 1, alignItems: \"center\", justifyContent: \"center\", padding: 24 },\n message: { fontSize: 16, textAlign: \"center\", marginBottom: 16 },\n retry: { borderWidth: 1, borderRadius: 20, paddingHorizontal: 20, paddingVertical: 10 },\n retryText: { fontSize: 15, fontWeight: \"600\" },\n footer: { paddingVertical: 24 },\n});\n","import React, { memo, useEffect, useRef, useState } from \"react\";\nimport { Image, Pressable, StyleSheet, Text, View } from \"react-native\";\nimport { ReelVideo } from \"./ReelVideo\";\nimport type { Reel } from \"@getnori/core\";\nimport type { ViewTracker } from \"./viewTracker\";\n\n/** How long an image reel stays on screen before the feed moves on (see #15). */\nexport const IMAGE_REEL_SECONDS = 15;\n\n/** The index to scroll to after `index`, or null at the end of the loaded reels. */\nexport function nextFeedIndex(index: number, count: number): number | null {\n return index + 1 < count ? index + 1 : null;\n}\n\nexport interface ReelOverlayContext {\n reel: Reel;\n /** This reel is the one on screen. */\n active: boolean;\n /** Playback position (0 for images). */\n positionMs: number;\n durationMs: number | null;\n}\n\nexport interface ReelItemProps {\n reel: Reel;\n active: boolean;\n height: number;\n muted: boolean;\n textColor: string;\n tracker: ViewTracker;\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** Called when an image reel has been on screen for IMAGE_REEL_SECONDS. */\n onImageShown?: () => void;\n}\n\nfunction ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay, onImageShown }: ReelItemProps) {\n const [userPaused, setUserPaused] = useState(false);\n const [positionMs, setPositionMs] = useState(0);\n const durationRef = useRef<number | null>(null);\n const [durationMs, setDurationMs] = useState<number | null>(null);\n const isVideo = reel.media_type === \"video\" && Boolean(reel.media_url);\n // Keep the latest callback in a ref so a parent re-render doesn't restart the image timer.\n const imageShownRef = useRef(onImageShown);\n imageShownRef.current = onImageShown;\n\n // An image reel stays on screen for IMAGE_REEL_SECONDS, then the feed moves on.\n useEffect(() => {\n if (!active || isVideo || !reel.media_url) return;\n const timer = setTimeout(() => imageShownRef.current?.(), IMAGE_REEL_SECONDS * 1000);\n return () => clearTimeout(timer);\n }, [active, isVideo, reel.media_url, reel.id]);\n\n // Becoming visible opens a view; returning to a loaded video re-opens it.\n useEffect(() => {\n if (!active) {\n setUserPaused(false);\n return;\n }\n tracker.activate(reel);\n if (durationRef.current) tracker.loaded(reel.id, durationRef.current);\n }, [active, reel, tracker]);\n\n return (\n <View style={[styles.reel, { height }]} testID={`nori-reel-${reel.id}`}>\n <Pressable\n style={StyleSheet.absoluteFill}\n onPress={() => isVideo && setUserPaused((p) => !p)}\n accessibilityRole=\"button\"\n accessibilityLabel={userPaused ? \"Play\" : \"Pause\"}\n disabled={!isVideo}\n >\n {isVideo ? (\n <ReelVideo\n testID={`nori-video-${reel.id}`}\n uri={reel.media_url as string}\n muted={muted}\n paused={!active || userPaused}\n onLoad={(seconds) => {\n const ms = seconds * 1000;\n durationRef.current = ms;\n setDurationMs(ms);\n if (active) tracker.loaded(reel.id, ms);\n }}\n onProgress={(seconds) => {\n const ms = seconds * 1000;\n setPositionMs(ms);\n if (active) tracker.progress(reel.id, ms);\n }}\n onEnd={() => active && tracker.ended(reel.id)}\n />\n ) : reel.media_url ? (\n <Image source={{ uri: reel.media_url }} style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n ) : null}\n </Pressable>\n\n {userPaused ? (\n <View pointerEvents=\"none\" style={styles.pausedBadge}>\n <Text style={styles.pausedText}>▶</Text>\n </View>\n ) : null}\n\n <View pointerEvents=\"box-none\" style={styles.caption}>\n {reel.ai_generated ? (\n <View style={styles.aiBadge} accessibilityLabel=\"AI-generated\" testID={`nori-ai-label-${reel.id}`}>\n <Text style={styles.aiText}>AI-generated</Text>\n </View>\n ) : null}\n {reel.title ? (\n <Text style={[styles.title, { color: textColor }]} numberOfLines={2}>\n {reel.title}\n </Text>\n ) : null}\n {reel.description ? (\n <Text style={[styles.description, { color: textColor }]} numberOfLines={3}>\n {reel.description}\n </Text>\n ) : null}\n </View>\n\n {renderOverlay ? (\n <View pointerEvents=\"box-none\" style={StyleSheet.absoluteFill}>\n {renderOverlay({ reel, active, positionMs, durationMs })}\n </View>\n ) : null}\n </View>\n );\n}\n\nexport const ReelItem = memo(ReelItemImpl);\n\n// react-native-video's web player ignores resizeMode and letterboxes; cover via CSS there.\n\nconst styles = StyleSheet.create({\n reel: { width: \"100%\", overflow: \"hidden\", backgroundColor: \"#000\" },\n caption: { position: \"absolute\", left: 16, right: 80, bottom: 120 },\n title: { fontSize: 17, fontWeight: \"700\", marginBottom: 4, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n description: { fontSize: 14, lineHeight: 19, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n pausedBadge: {\n position: \"absolute\", alignSelf: \"center\", top: \"45%\", width: 64, height: 64, borderRadius: 32,\n backgroundColor: \"rgba(0,0,0,0.45)\", alignItems: \"center\", justifyContent: \"center\",\n },\n pausedText: { color: \"#fff\", fontSize: 26, marginLeft: 4 },\n aiBadge: {\n alignSelf: \"flex-start\", marginBottom: 6, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10,\n backgroundColor: \"rgba(0,0,0,0.55)\", borderWidth: 1, borderColor: \"rgba(255,255,255,0.35)\",\n },\n aiText: { color: \"#fff\", fontSize: 11, fontWeight: \"700\", letterSpacing: 0.3 },\n});\n","/** Native reel video (iOS, Android): react-native-video. The web build uses ReelVideo.web.tsx. */\nimport React from \"react\";\nimport Video from \"react-native-video\";\nimport { VIDEO_STYLE } from \"./videoStyle\";\n\nexport interface ReelVideoProps {\n testID?: string;\n uri: string;\n muted: boolean;\n paused: boolean;\n /** Duration in seconds, once known. */\n onLoad: (durationSeconds: number) => void;\n /** Playback position in seconds. */\n onProgress: (positionSeconds: number) => void;\n onEnd: () => void;\n}\n\nexport function ReelVideo({ testID, uri, muted, paused, onLoad, onProgress, onEnd }: ReelVideoProps) {\n return (\n <Video\n testID={testID}\n source={{ uri }}\n style={VIDEO_STYLE}\n resizeMode=\"cover\"\n repeat\n muted={muted}\n paused={paused}\n progressUpdateInterval={250}\n playInBackground={false}\n onLoad={(data: { duration: number }) => onLoad(data.duration)}\n onProgress={(data: { currentTime: number }) => onProgress(data.currentTime)}\n onEnd={onEnd}\n />\n );\n}\n","import { Platform, StyleSheet } from \"react-native\";\n\n/** Full-bleed video. On the web the browser's object-fit does the cover fit. */\nexport const VIDEO_STYLE =\n Platform.OS === \"web\" ? [StyleSheet.absoluteFill, { objectFit: \"cover\" } as object] : StyleSheet.absoluteFill;\n","import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { NoriClient, NoriError, Reel } from \"@getnori/core\";\n\nexport interface UseFeedOptions {\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page (1-50). Default 10. */\n pageSize?: number;\n}\n\nexport interface FeedState {\n reels: Reel[];\n /** First page loading. */\n loading: boolean;\n loadingMore: boolean;\n refreshing: boolean;\n error: NoriError | Error | null;\n /** This viewer is in the holdout group: the feed is intentionally empty. */\n holdout: boolean;\n hasMore: boolean;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n}\n\n/** Pages through the viewer's feed. Reels already shown are never duplicated. */\nexport function useFeed(client: NoriClient, { tag = null, pageSize = 10 }: UseFeedOptions = {}): FeedState {\n const [reels, setReels] = useState<Reel[]>([]);\n const [loading, setLoading] = useState(true);\n const [loadingMore, setLoadingMore] = useState(false);\n const [refreshing, setRefreshing] = useState(false);\n const [error, setError] = useState<NoriError | Error | null>(null);\n const [holdout, setHoldout] = useState(false);\n const [cursor, setCursor] = useState<string | null>(null);\n const [hasMore, setHasMore] = useState(true);\n\n const busy = useRef(false);\n const generation = useRef(0); // ignores responses from before a refresh or a tag change\n const limit = Math.min(Math.max(1, Math.round(pageSize)), 50);\n\n const load = useCallback(\n async (mode: \"first\" | \"more\" | \"refresh\", fromCursor: string | null) => {\n if (busy.current && mode === \"more\") return;\n busy.current = true;\n const gen = mode === \"more\" ? generation.current : ++generation.current;\n if (mode === \"more\") setLoadingMore(true);\n if (mode === \"refresh\") setRefreshing(true);\n try {\n const page = await client.feed({ limit, cursor: fromCursor, tag });\n if (gen !== generation.current) return;\n setError(null);\n setHoldout(Boolean(page.holdout));\n setCursor(page.next_cursor ?? null);\n setHasMore(Boolean(page.next_cursor));\n setReels((prev) => {\n const base = mode === \"more\" ? prev : [];\n const seen = new Set(base.map((r) => r.id));\n return [...base, ...page.reels.filter((r) => !seen.has(r.id))];\n });\n } catch (e) {\n if (gen === generation.current) setError(e as Error);\n } finally {\n if (gen === generation.current) {\n busy.current = false;\n setLoading(false);\n setLoadingMore(false);\n setRefreshing(false);\n }\n }\n },\n [client, limit, tag],\n );\n\n useEffect(() => {\n setLoading(true);\n setReels([]);\n void load(\"first\", null);\n }, [load]);\n\n const loadMore = useCallback(async () => {\n if (!hasMore || !cursor || loading || holdout) return;\n await load(\"more\", cursor);\n }, [cursor, hasMore, holdout, load, loading]);\n\n const refresh = useCallback(() => load(\"refresh\", null), [load]);\n\n return { reels, loading, loadingMore, refreshing, error, holdout, hasMore, loadMore, refresh };\n}\n","import type { NoriClient, Reel, TrackInput } from \"@getnori/core\";\n\nexport interface ViewTrackerOptions {\n /** Report view_complete once playback reaches this share of the reel (config.rewards.min_watch_percent). */\n minWatchPercent: number;\n /** ...and the viewer has watched at least this long, counting loops (config.rewards.min_view_seconds). */\n minViewSeconds?: number;\n /** Clock in ms (tests). Default Date.now. */\n now?: () => number;\n}\n\nconst PROGRESS_MILESTONES = [25, 50, 75];\n/** A gap between progress callbacks longer than this (paused, backgrounded) doesn't count as watching. */\nconst MAX_TICK_MS = 1500;\n\ninterface View {\n reelId: number;\n durationMs: number | null;\n milestones: Set<number>;\n opened: boolean;\n furthestMs: number;\n watchedMs: number;\n lastTickAt: number | null;\n}\n\n/**\n * Turns playback callbacks into the events the API expects for one reel.\n *\n * becomes visible impression (once per reel per feed)\n * video loaded view_progress @0 (sent at once: opens the view on the server's clock)\n * 25 / 50 / 75 % view_progress\n * complete view_complete (once; the server decides the reward)\n *\n * \"Complete\" needs both conditions the server checks: the furthest position\n * reaches minWatchPercent of the reel, and the viewer has watched for at\n * least max(that share of the reel, minViewSeconds), counting loops. So a\n * 5-second reel with a 5-second minimum completes after one full play, not\n * at 80 % (which the server would refuse as \"watched too fast\").\n *\n * Images get an impression only: a reward needs a watched video.\n */\nexport class ViewTracker {\n private impressions = new Set<number>();\n private completed = new Set<number>();\n private current: View | null = null;\n private readonly now: () => number;\n\n constructor(\n private readonly track: (input: TrackInput) => void,\n private readonly options: ViewTrackerOptions,\n ) {\n this.now = options.now ?? (() => Date.now());\n }\n\n /** The reel is now the visible one. */\n activate(reel: Pick<Reel, \"id\" | \"media_type\">): void {\n if (this.current?.reelId === reel.id) return;\n this.current = {\n reelId: reel.id, durationMs: null, milestones: new Set(), opened: false,\n furthestMs: 0, watchedMs: 0, lastTickAt: null,\n };\n if (!this.impressions.has(reel.id)) {\n this.impressions.add(reel.id);\n this.track({ type: \"impression\", reel_id: reel.id });\n }\n }\n\n /** The visible video loaded and knows its duration. */\n loaded(reelId: number, durationMs: number): void {\n const view = this.view(reelId);\n if (!view || !(durationMs > 0)) return;\n view.durationMs = Math.round(durationMs);\n if (!view.opened) {\n view.opened = true;\n view.lastTickAt = this.now();\n // Sent at once: the server times the view from when this arrives.\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });\n }\n }\n\n /** Playback position of the visible video (called while it plays). */\n progress(reelId: number, positionMs: number): void {\n const view = this.view(reelId);\n if (!view || !view.durationMs || !view.opened) return;\n const now = this.now();\n if (view.lastTickAt !== null) {\n const gap = now - view.lastTickAt;\n if (gap > 0 && gap <= MAX_TICK_MS) view.watchedMs += gap;\n }\n view.lastTickAt = now;\n const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));\n view.furthestMs = Math.max(view.furthestMs, position);\n const percent = (view.furthestMs / view.durationMs) * 100;\n\n if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {\n const required = Math.max(\n (view.durationMs * this.options.minWatchPercent) / 100,\n (this.options.minViewSeconds ?? 0) * 1000,\n );\n if (view.watchedMs >= required) {\n this.completed.add(reelId);\n this.track({ type: \"view_complete\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n return;\n }\n }\n for (const milestone of PROGRESS_MILESTONES) {\n if (percent >= milestone && !view.milestones.has(milestone)) {\n view.milestones.add(milestone);\n if (!this.completed.has(reelId)) {\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n }\n }\n }\n }\n\n /** The video reached its end (it then loops). */\n ended(reelId: number): void {\n const view = this.view(reelId);\n if (view?.durationMs) this.progress(reelId, view.durationMs);\n }\n\n /** The viewer scrolled away; a return opens a new view. */\n deactivate(): void {\n this.current = null;\n }\n\n private view(reelId: number) {\n return this.current && this.current.reelId === reelId ? this.current : null;\n }\n}\n\nexport function createViewTracker(client: Pick<NoriClient, \"track\">, options: ViewTrackerOptions): ViewTracker {\n return new ViewTracker((input) => {\n try {\n client.track(input);\n } catch {\n // A disposed client must not crash playback.\n }\n }, options);\n}\n"],"mappings":"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","names":["useCallback","useEffect","useRef","useState","Pressable","StyleSheet","Text","View","StyleSheet","jsx","StyleSheet","useEffect","useRef","useState","jsx","jsxs","useState","useEffect","useRef","useCallback","styles","View","Text","Pressable","StyleSheet"]}
package/dist/web/web.js CHANGED
@@ -324,6 +324,7 @@ function NoriFeed({
324
324
  renderEmpty,
325
325
  renderHoldout,
326
326
  height: fixedHeight,
327
+ paused = false,
327
328
  style,
328
329
  testID = "nori-feed"
329
330
  }) {
@@ -403,7 +404,7 @@ function NoriFeed({
403
404
  ReelItem,
404
405
  {
405
406
  reel: item,
406
- active: index === activeIndex,
407
+ active: index === activeIndex && !paused,
407
408
  height,
408
409
  muted,
409
410
  textColor,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/NoriFeed.tsx","../../src/ReelItem.tsx","../../src/ReelVideo.web.tsx","../../src/videoStyle.ts","../../src/useFeed.ts","../../src/viewTracker.ts"],"sourcesContent":["import React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n ActivityIndicator,\n AppState,\n FlatList,\n Pressable,\n StyleSheet,\n Text,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type FlatListProps,\n type ViewStyle,\n} from \"react-native\";\nimport type { Config, NoriClient, Reel } from \"@getnori/core\";\nimport { ReelItem, nextFeedIndex, type ReelOverlayContext } from \"./ReelItem\";\nimport { useFeed } from \"./useFeed\";\nimport { createViewTracker } from \"./viewTracker\";\n\nexport interface NoriFeedProps {\n client: NoriClient;\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page. Default 10. */\n pageSize?: number;\n /** Start muted. Default false. */\n muted?: boolean;\n /**\n * Drawn over each reel, e.g. Commerce product cards or a Rewards badge:\n * `renderOverlay={(ctx) => <ProductStrip client={nori} reel={ctx.reel} positionMs={ctx.positionMs} ... />}`\n */\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** The visible reel changed. */\n onReelChange?: (reel: Reel, index: number) => void;\n /** Shown when there are no reels. */\n renderEmpty?: () => React.ReactNode;\n /**\n * Shown to holdout viewers (the experiment's control group, who see no\n * feed). Default: nothing. Show your app's usual content here.\n */\n renderHoldout?: () => React.ReactNode;\n /** Fixed reel height. Default: the height of the feed's own container. */\n height?: number;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_MIN_WATCH_PERCENT = 90;\nconst DEFAULT_MIN_VIEW_SECONDS = 5;\n\n/** A full-height, vertically paged reel feed. Fills its parent. */\nexport function NoriFeed({\n client,\n tag = null,\n pageSize = 10,\n muted = false,\n renderOverlay,\n onReelChange,\n renderEmpty,\n renderHoldout,\n height: fixedHeight,\n style,\n testID = \"nori-feed\",\n}: NoriFeedProps) {\n const feed = useFeed(client, { tag, pageSize });\n const [config, setConfig] = useState<Config | null>(null);\n const [measuredHeight, setHeight] = useState(0);\n const height = fixedHeight ?? measuredHeight;\n const [activeIndex, setActiveIndex] = useState(0);\n\n useEffect(() => {\n let live = true;\n client.config().then(\n (c) => live && setConfig(c),\n () => undefined, // theme and reward rules have defaults\n );\n return () => {\n live = false;\n };\n }, [client]);\n\n const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;\n const minViewSeconds = config?.rewards.min_view_seconds ?? DEFAULT_MIN_VIEW_SECONDS;\n const tracker = useMemo(\n () => createViewTracker(client, { minWatchPercent, minViewSeconds }),\n [client, minWatchPercent, minViewSeconds],\n );\n\n // Send queued events when the app goes to the background.\n useEffect(() => {\n const sub = AppState.addEventListener(\"change\", (state) => {\n if (state !== \"active\") void client.flush().catch(() => undefined);\n });\n return () => sub.remove();\n }, [client]);\n\n const onReelChangeRef = useRef(onReelChange);\n onReelChangeRef.current = onReelChange;\n const reelsRef = useRef(feed.reels);\n reelsRef.current = feed.reels;\n\n const listRef = useRef<FlatList<Reel>>(null);\n const advanceFrom = useCallback((index: number) => {\n const next = nextFeedIndex(index, reelsRef.current.length);\n if (next != null) listRef.current?.scrollToIndex({ index: next, animated: true });\n }, []);\n\n const onViewableItemsChanged = useRef<NonNullable<FlatListProps<Reel>[\"onViewableItemsChanged\"]>>(({ viewableItems }) => {\n const first = viewableItems.find((v) => v.isViewable && v.index !== null);\n if (first?.index == null) return;\n setActiveIndex(first.index);\n const reel = reelsRef.current[first.index];\n if (reel) onReelChangeRef.current?.(reel, first.index);\n }).current;\n const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 80 }).current;\n\n const onLayout = useCallback((e: LayoutChangeEvent) => {\n const h = Math.round(e.nativeEvent.layout.height);\n if (h > 0) setHeight(h);\n }, []);\n\n const theme = config?.theme ?? {};\n const background = theme.background_color ?? \"#000\";\n const textColor = theme.text_color ?? \"#fff\";\n const accent = theme.primary_color ?? \"#fff\";\n\n let body: React.ReactNode;\n if (feed.loading && feed.reels.length === 0) {\n body = <ActivityIndicator color={accent} style={styles.center} testID={`${testID}-loading`} />;\n } else if (feed.holdout) {\n body = renderHoldout ? renderHoldout() : null;\n } else if (feed.error && feed.reels.length === 0) {\n body = (\n <View style={styles.center} testID={`${testID}-error`}>\n <Text style={[styles.message, { color: textColor }]}>Couldn't load the feed.</Text>\n <Pressable onPress={() => void feed.refresh()} accessibilityRole=\"button\" style={[styles.retry, { borderColor: accent }]}>\n <Text style={[styles.retryText, { color: accent }]}>Try again</Text>\n </Pressable>\n </View>\n );\n } else if (feed.reels.length === 0) {\n body = renderEmpty ? renderEmpty() : (\n <View style={styles.center} testID={`${testID}-empty`}>\n <Text style={[styles.message, { color: textColor }]}>Nothing here yet.</Text>\n </View>\n );\n } else if (height > 0) {\n body = (\n <FlatList\n ref={listRef}\n data={feed.reels}\n keyExtractor={(reel) => String(reel.id)}\n renderItem={({ item, index }) => (\n <ReelItem\n reel={item}\n active={index === activeIndex}\n height={height}\n muted={muted}\n textColor={textColor}\n tracker={tracker}\n renderOverlay={renderOverlay}\n onImageShown={() => advanceFrom(index)}\n />\n )}\n getItemLayout={(_, index) => ({ length: height, offset: height * index, index })}\n pagingEnabled\n snapToInterval={height}\n snapToAlignment=\"start\"\n decelerationRate=\"fast\"\n showsVerticalScrollIndicator={false}\n onViewableItemsChanged={onViewableItemsChanged}\n viewabilityConfig={viewabilityConfig}\n onEndReached={() => void feed.loadMore()}\n onEndReachedThreshold={2}\n refreshing={feed.refreshing}\n onRefresh={() => void feed.refresh()}\n windowSize={3}\n initialNumToRender={2}\n maxToRenderPerBatch={2}\n removeClippedSubviews\n ListFooterComponent={feed.loadingMore ? <ActivityIndicator color={accent} style={styles.footer} /> : undefined}\n />\n );\n }\n\n return (\n <View style={[styles.container, { backgroundColor: background }, style]} onLayout={onLayout} testID={testID}>\n {body}\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n container: { flex: 1, overflow: \"hidden\" },\n center: { flex: 1, alignItems: \"center\", justifyContent: \"center\", padding: 24 },\n message: { fontSize: 16, textAlign: \"center\", marginBottom: 16 },\n retry: { borderWidth: 1, borderRadius: 20, paddingHorizontal: 20, paddingVertical: 10 },\n retryText: { fontSize: 15, fontWeight: \"600\" },\n footer: { paddingVertical: 24 },\n});\n","import React, { memo, useEffect, useRef, useState } from \"react\";\nimport { Image, Pressable, StyleSheet, Text, View } from \"react-native\";\nimport { ReelVideo } from \"./ReelVideo\";\nimport type { Reel } from \"@getnori/core\";\nimport type { ViewTracker } from \"./viewTracker\";\n\n/** How long an image reel stays on screen before the feed moves on (see #15). */\nexport const IMAGE_REEL_SECONDS = 15;\n\n/** The index to scroll to after `index`, or null at the end of the loaded reels. */\nexport function nextFeedIndex(index: number, count: number): number | null {\n return index + 1 < count ? index + 1 : null;\n}\n\nexport interface ReelOverlayContext {\n reel: Reel;\n /** This reel is the one on screen. */\n active: boolean;\n /** Playback position (0 for images). */\n positionMs: number;\n durationMs: number | null;\n}\n\nexport interface ReelItemProps {\n reel: Reel;\n active: boolean;\n height: number;\n muted: boolean;\n textColor: string;\n tracker: ViewTracker;\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** Called when an image reel has been on screen for IMAGE_REEL_SECONDS. */\n onImageShown?: () => void;\n}\n\nfunction ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay, onImageShown }: ReelItemProps) {\n const [userPaused, setUserPaused] = useState(false);\n const [positionMs, setPositionMs] = useState(0);\n const durationRef = useRef<number | null>(null);\n const [durationMs, setDurationMs] = useState<number | null>(null);\n const isVideo = reel.media_type === \"video\" && Boolean(reel.media_url);\n // Keep the latest callback in a ref so a parent re-render doesn't restart the image timer.\n const imageShownRef = useRef(onImageShown);\n imageShownRef.current = onImageShown;\n\n // An image reel stays on screen for IMAGE_REEL_SECONDS, then the feed moves on.\n useEffect(() => {\n if (!active || isVideo || !reel.media_url) return;\n const timer = setTimeout(() => imageShownRef.current?.(), IMAGE_REEL_SECONDS * 1000);\n return () => clearTimeout(timer);\n }, [active, isVideo, reel.media_url, reel.id]);\n\n // Becoming visible opens a view; returning to a loaded video re-opens it.\n useEffect(() => {\n if (!active) {\n setUserPaused(false);\n return;\n }\n tracker.activate(reel);\n if (durationRef.current) tracker.loaded(reel.id, durationRef.current);\n }, [active, reel, tracker]);\n\n return (\n <View style={[styles.reel, { height }]} testID={`nori-reel-${reel.id}`}>\n <Pressable\n style={StyleSheet.absoluteFill}\n onPress={() => isVideo && setUserPaused((p) => !p)}\n accessibilityRole=\"button\"\n accessibilityLabel={userPaused ? \"Play\" : \"Pause\"}\n disabled={!isVideo}\n >\n {isVideo ? (\n <ReelVideo\n testID={`nori-video-${reel.id}`}\n uri={reel.media_url as string}\n muted={muted}\n paused={!active || userPaused}\n onLoad={(seconds) => {\n const ms = seconds * 1000;\n durationRef.current = ms;\n setDurationMs(ms);\n if (active) tracker.loaded(reel.id, ms);\n }}\n onProgress={(seconds) => {\n const ms = seconds * 1000;\n setPositionMs(ms);\n if (active) tracker.progress(reel.id, ms);\n }}\n onEnd={() => active && tracker.ended(reel.id)}\n />\n ) : reel.media_url ? (\n <Image source={{ uri: reel.media_url }} style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n ) : null}\n </Pressable>\n\n {userPaused ? (\n <View pointerEvents=\"none\" style={styles.pausedBadge}>\n <Text style={styles.pausedText}>▶</Text>\n </View>\n ) : null}\n\n <View pointerEvents=\"box-none\" style={styles.caption}>\n {reel.ai_generated ? (\n <View style={styles.aiBadge} accessibilityLabel=\"AI-generated\" testID={`nori-ai-label-${reel.id}`}>\n <Text style={styles.aiText}>AI-generated</Text>\n </View>\n ) : null}\n {reel.title ? (\n <Text style={[styles.title, { color: textColor }]} numberOfLines={2}>\n {reel.title}\n </Text>\n ) : null}\n {reel.description ? (\n <Text style={[styles.description, { color: textColor }]} numberOfLines={3}>\n {reel.description}\n </Text>\n ) : null}\n </View>\n\n {renderOverlay ? (\n <View pointerEvents=\"box-none\" style={StyleSheet.absoluteFill}>\n {renderOverlay({ reel, active, positionMs, durationMs })}\n </View>\n ) : null}\n </View>\n );\n}\n\nexport const ReelItem = memo(ReelItemImpl);\n\n// react-native-video's web player ignores resizeMode and letterboxes; cover via CSS there.\n\nconst styles = StyleSheet.create({\n reel: { width: \"100%\", overflow: \"hidden\", backgroundColor: \"#000\" },\n caption: { position: \"absolute\", left: 16, right: 80, bottom: 120 },\n title: { fontSize: 17, fontWeight: \"700\", marginBottom: 4, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n description: { fontSize: 14, lineHeight: 19, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n pausedBadge: {\n position: \"absolute\", alignSelf: \"center\", top: \"45%\", width: 64, height: 64, borderRadius: 32,\n backgroundColor: \"rgba(0,0,0,0.45)\", alignItems: \"center\", justifyContent: \"center\",\n },\n pausedText: { color: \"#fff\", fontSize: 26, marginLeft: 4 },\n aiBadge: {\n alignSelf: \"flex-start\", marginBottom: 6, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10,\n backgroundColor: \"rgba(0,0,0,0.55)\", borderWidth: 1, borderColor: \"rgba(255,255,255,0.35)\",\n },\n aiText: { color: \"#fff\", fontSize: 11, fontWeight: \"700\", letterSpacing: 0.3 },\n});\n","/**\n * Web reel video: a DOM <video> rendered inside react-native-web. Bundlers pick this file for web\n * builds (Expo web, webpack, vite with react-native-web); native builds use ReelVideo.tsx.\n */\nimport React, { useEffect, useRef } from \"react\";\nimport { View } from \"react-native\";\nimport type { ReelVideoProps } from \"./ReelVideo\";\nimport { VIDEO_STYLE } from \"./videoStyle\";\n\nexport function ReelVideo({ testID, uri, muted, paused, onLoad, onProgress, onEnd }: ReelVideoProps) {\n const ref = useRef<HTMLVideoElement | null>(null);\n\n useEffect(() => {\n const video = ref.current;\n if (!video) return;\n if (paused) {\n video.pause();\n return;\n }\n // Browsers refuse autoplay with sound until the user has interacted; the muted prop\n // is how the app keeps reels playing.\n video.play()?.catch(() => undefined);\n }, [paused, uri]);\n\n // The DOM element is created inside react-native-web's View; style it like the native video.\n return (\n <View style={VIDEO_STYLE} testID={testID}>\n <video\n ref={(el) => {\n ref.current = el;\n }}\n src={uri}\n muted={muted}\n loop\n playsInline\n preload=\"auto\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n onLoadedMetadata={(e) => {\n const duration = (e.currentTarget as HTMLVideoElement).duration;\n if (Number.isFinite(duration) && duration > 0) onLoad(duration);\n }}\n onTimeUpdate={(e) => onProgress((e.currentTarget as HTMLVideoElement).currentTime)}\n onEnded={() => onEnd()}\n />\n </View>\n );\n}\n","import { Platform, StyleSheet } from \"react-native\";\n\n/** Full-bleed video. On the web the browser's object-fit does the cover fit. */\nexport const VIDEO_STYLE =\n Platform.OS === \"web\" ? [StyleSheet.absoluteFill, { objectFit: \"cover\" } as object] : StyleSheet.absoluteFill;\n","import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { NoriClient, NoriError, Reel } from \"@getnori/core\";\n\nexport interface UseFeedOptions {\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page (1-50). Default 10. */\n pageSize?: number;\n}\n\nexport interface FeedState {\n reels: Reel[];\n /** First page loading. */\n loading: boolean;\n loadingMore: boolean;\n refreshing: boolean;\n error: NoriError | Error | null;\n /** This viewer is in the holdout group: the feed is intentionally empty. */\n holdout: boolean;\n hasMore: boolean;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n}\n\n/** Pages through the viewer's feed. Reels already shown are never duplicated. */\nexport function useFeed(client: NoriClient, { tag = null, pageSize = 10 }: UseFeedOptions = {}): FeedState {\n const [reels, setReels] = useState<Reel[]>([]);\n const [loading, setLoading] = useState(true);\n const [loadingMore, setLoadingMore] = useState(false);\n const [refreshing, setRefreshing] = useState(false);\n const [error, setError] = useState<NoriError | Error | null>(null);\n const [holdout, setHoldout] = useState(false);\n const [cursor, setCursor] = useState<string | null>(null);\n const [hasMore, setHasMore] = useState(true);\n\n const busy = useRef(false);\n const generation = useRef(0); // ignores responses from before a refresh or a tag change\n const limit = Math.min(Math.max(1, Math.round(pageSize)), 50);\n\n const load = useCallback(\n async (mode: \"first\" | \"more\" | \"refresh\", fromCursor: string | null) => {\n if (busy.current && mode === \"more\") return;\n busy.current = true;\n const gen = mode === \"more\" ? generation.current : ++generation.current;\n if (mode === \"more\") setLoadingMore(true);\n if (mode === \"refresh\") setRefreshing(true);\n try {\n const page = await client.feed({ limit, cursor: fromCursor, tag });\n if (gen !== generation.current) return;\n setError(null);\n setHoldout(Boolean(page.holdout));\n setCursor(page.next_cursor ?? null);\n setHasMore(Boolean(page.next_cursor));\n setReels((prev) => {\n const base = mode === \"more\" ? prev : [];\n const seen = new Set(base.map((r) => r.id));\n return [...base, ...page.reels.filter((r) => !seen.has(r.id))];\n });\n } catch (e) {\n if (gen === generation.current) setError(e as Error);\n } finally {\n if (gen === generation.current) {\n busy.current = false;\n setLoading(false);\n setLoadingMore(false);\n setRefreshing(false);\n }\n }\n },\n [client, limit, tag],\n );\n\n useEffect(() => {\n setLoading(true);\n setReels([]);\n void load(\"first\", null);\n }, [load]);\n\n const loadMore = useCallback(async () => {\n if (!hasMore || !cursor || loading || holdout) return;\n await load(\"more\", cursor);\n }, [cursor, hasMore, holdout, load, loading]);\n\n const refresh = useCallback(() => load(\"refresh\", null), [load]);\n\n return { reels, loading, loadingMore, refreshing, error, holdout, hasMore, loadMore, refresh };\n}\n","import type { NoriClient, Reel, TrackInput } from \"@getnori/core\";\n\nexport interface ViewTrackerOptions {\n /** Report view_complete once playback reaches this share of the reel (config.rewards.min_watch_percent). */\n minWatchPercent: number;\n /** ...and the viewer has watched at least this long, counting loops (config.rewards.min_view_seconds). */\n minViewSeconds?: number;\n /** Clock in ms (tests). Default Date.now. */\n now?: () => number;\n}\n\nconst PROGRESS_MILESTONES = [25, 50, 75];\n/** A gap between progress callbacks longer than this (paused, backgrounded) doesn't count as watching. */\nconst MAX_TICK_MS = 1500;\n\ninterface View {\n reelId: number;\n durationMs: number | null;\n milestones: Set<number>;\n opened: boolean;\n furthestMs: number;\n watchedMs: number;\n lastTickAt: number | null;\n}\n\n/**\n * Turns playback callbacks into the events the API expects for one reel.\n *\n * becomes visible impression (once per reel per feed)\n * video loaded view_progress @0 (sent at once: opens the view on the server's clock)\n * 25 / 50 / 75 % view_progress\n * complete view_complete (once; the server decides the reward)\n *\n * \"Complete\" needs both conditions the server checks: the furthest position\n * reaches minWatchPercent of the reel, and the viewer has watched for at\n * least max(that share of the reel, minViewSeconds), counting loops. So a\n * 5-second reel with a 5-second minimum completes after one full play, not\n * at 80 % (which the server would refuse as \"watched too fast\").\n *\n * Images get an impression only: a reward needs a watched video.\n */\nexport class ViewTracker {\n private impressions = new Set<number>();\n private completed = new Set<number>();\n private current: View | null = null;\n private readonly now: () => number;\n\n constructor(\n private readonly track: (input: TrackInput) => void,\n private readonly options: ViewTrackerOptions,\n ) {\n this.now = options.now ?? (() => Date.now());\n }\n\n /** The reel is now the visible one. */\n activate(reel: Pick<Reel, \"id\" | \"media_type\">): void {\n if (this.current?.reelId === reel.id) return;\n this.current = {\n reelId: reel.id, durationMs: null, milestones: new Set(), opened: false,\n furthestMs: 0, watchedMs: 0, lastTickAt: null,\n };\n if (!this.impressions.has(reel.id)) {\n this.impressions.add(reel.id);\n this.track({ type: \"impression\", reel_id: reel.id });\n }\n }\n\n /** The visible video loaded and knows its duration. */\n loaded(reelId: number, durationMs: number): void {\n const view = this.view(reelId);\n if (!view || !(durationMs > 0)) return;\n view.durationMs = Math.round(durationMs);\n if (!view.opened) {\n view.opened = true;\n view.lastTickAt = this.now();\n // Sent at once: the server times the view from when this arrives.\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });\n }\n }\n\n /** Playback position of the visible video (called while it plays). */\n progress(reelId: number, positionMs: number): void {\n const view = this.view(reelId);\n if (!view || !view.durationMs || !view.opened) return;\n const now = this.now();\n if (view.lastTickAt !== null) {\n const gap = now - view.lastTickAt;\n if (gap > 0 && gap <= MAX_TICK_MS) view.watchedMs += gap;\n }\n view.lastTickAt = now;\n const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));\n view.furthestMs = Math.max(view.furthestMs, position);\n const percent = (view.furthestMs / view.durationMs) * 100;\n\n if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {\n const required = Math.max(\n (view.durationMs * this.options.minWatchPercent) / 100,\n (this.options.minViewSeconds ?? 0) * 1000,\n );\n if (view.watchedMs >= required) {\n this.completed.add(reelId);\n this.track({ type: \"view_complete\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n return;\n }\n }\n for (const milestone of PROGRESS_MILESTONES) {\n if (percent >= milestone && !view.milestones.has(milestone)) {\n view.milestones.add(milestone);\n if (!this.completed.has(reelId)) {\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n }\n }\n }\n }\n\n /** The video reached its end (it then loops). */\n ended(reelId: number): void {\n const view = this.view(reelId);\n if (view?.durationMs) this.progress(reelId, view.durationMs);\n }\n\n /** The viewer scrolled away; a return opens a new view. */\n deactivate(): void {\n this.current = null;\n }\n\n private view(reelId: number) {\n return this.current && this.current.reelId === reelId ? this.current : null;\n }\n}\n\nexport function createViewTracker(client: Pick<NoriClient, \"track\">, options: ViewTrackerOptions): ViewTracker {\n return new ViewTracker((input) => {\n try {\n client.track(input);\n } catch {\n // A disposed client must not crash playback.\n }\n }, options);\n}\n"],"mappings":"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","names":["useCallback","useEffect","useRef","useState","Pressable","StyleSheet","Text","View","useEffect","useRef","StyleSheet","View","jsx","useRef","useEffect","View","StyleSheet","useEffect","useRef","useState","jsx","jsxs","useState","useEffect","useRef","useCallback","styles","View","Text","Pressable","StyleSheet"]}
1
+ {"version":3,"sources":["../../src/NoriFeed.tsx","../../src/ReelItem.tsx","../../src/ReelVideo.web.tsx","../../src/videoStyle.ts","../../src/useFeed.ts","../../src/viewTracker.ts"],"sourcesContent":["import React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n ActivityIndicator,\n AppState,\n FlatList,\n Pressable,\n StyleSheet,\n Text,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type FlatListProps,\n type ViewStyle,\n} from \"react-native\";\nimport type { Config, NoriClient, Reel } from \"@getnori/core\";\nimport { ReelItem, nextFeedIndex, type ReelOverlayContext } from \"./ReelItem\";\nimport { useFeed } from \"./useFeed\";\nimport { createViewTracker } from \"./viewTracker\";\n\nexport interface NoriFeedProps {\n client: NoriClient;\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page. Default 10. */\n pageSize?: number;\n /** Start muted. Default false. */\n muted?: boolean;\n /**\n * Drawn over each reel, e.g. Commerce product cards or a Rewards badge:\n * `renderOverlay={(ctx) => <ProductStrip client={nori} reel={ctx.reel} positionMs={ctx.positionMs} ... />}`\n */\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** The visible reel changed. */\n onReelChange?: (reel: Reel, index: number) => void;\n /** Shown when there are no reels. */\n renderEmpty?: () => React.ReactNode;\n /**\n * Shown to holdout viewers (the experiment's control group, who see no\n * feed). Default: nothing. Show your app's usual content here.\n */\n renderHoldout?: () => React.ReactNode;\n /**\n * Stop playback and the image timer, e.g. while the feed's tab is in the background.\n * Pass `!isFocused` from React Navigation. Default: false.\n */\n paused?: boolean;\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 paused = false,\n style,\n testID = \"nori-feed\",\n}: NoriFeedProps) {\n const feed = useFeed(client, { tag, pageSize });\n const [config, setConfig] = useState<Config | null>(null);\n const [measuredHeight, setHeight] = useState(0);\n const height = fixedHeight ?? measuredHeight;\n const [activeIndex, setActiveIndex] = useState(0);\n\n useEffect(() => {\n let live = true;\n client.config().then(\n (c) => live && setConfig(c),\n () => undefined, // theme and reward rules have defaults\n );\n return () => {\n live = false;\n };\n }, [client]);\n\n const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;\n const minViewSeconds = config?.rewards.min_view_seconds ?? DEFAULT_MIN_VIEW_SECONDS;\n const tracker = useMemo(\n () => createViewTracker(client, { minWatchPercent, minViewSeconds }),\n [client, minWatchPercent, minViewSeconds],\n );\n\n // Send queued events when the app goes to the background.\n useEffect(() => {\n const sub = AppState.addEventListener(\"change\", (state) => {\n if (state !== \"active\") void client.flush().catch(() => undefined);\n });\n return () => sub.remove();\n }, [client]);\n\n const onReelChangeRef = useRef(onReelChange);\n onReelChangeRef.current = onReelChange;\n const reelsRef = useRef(feed.reels);\n reelsRef.current = feed.reels;\n\n const listRef = useRef<FlatList<Reel>>(null);\n const advanceFrom = useCallback((index: number) => {\n const next = nextFeedIndex(index, reelsRef.current.length);\n if (next != null) listRef.current?.scrollToIndex({ index: next, animated: true });\n }, []);\n\n const onViewableItemsChanged = useRef<NonNullable<FlatListProps<Reel>[\"onViewableItemsChanged\"]>>(({ viewableItems }) => {\n const first = viewableItems.find((v) => v.isViewable && v.index !== null);\n if (first?.index == null) return;\n setActiveIndex(first.index);\n const reel = reelsRef.current[first.index];\n if (reel) onReelChangeRef.current?.(reel, first.index);\n }).current;\n const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 80 }).current;\n\n const onLayout = useCallback((e: LayoutChangeEvent) => {\n const h = Math.round(e.nativeEvent.layout.height);\n if (h > 0) setHeight(h);\n }, []);\n\n const theme = config?.theme ?? {};\n const background = theme.background_color ?? \"#000\";\n const textColor = theme.text_color ?? \"#fff\";\n const accent = theme.primary_color ?? \"#fff\";\n\n let body: React.ReactNode;\n if (feed.loading && feed.reels.length === 0) {\n body = <ActivityIndicator color={accent} style={styles.center} testID={`${testID}-loading`} />;\n } else if (feed.holdout) {\n body = renderHoldout ? renderHoldout() : null;\n } else if (feed.error && feed.reels.length === 0) {\n body = (\n <View style={styles.center} testID={`${testID}-error`}>\n <Text style={[styles.message, { color: textColor }]}>Couldn't load the feed.</Text>\n <Pressable onPress={() => void feed.refresh()} accessibilityRole=\"button\" style={[styles.retry, { borderColor: accent }]}>\n <Text style={[styles.retryText, { color: accent }]}>Try again</Text>\n </Pressable>\n </View>\n );\n } else if (feed.reels.length === 0) {\n body = renderEmpty ? renderEmpty() : (\n <View style={styles.center} testID={`${testID}-empty`}>\n <Text style={[styles.message, { color: textColor }]}>Nothing here yet.</Text>\n </View>\n );\n } else if (height > 0) {\n body = (\n <FlatList\n ref={listRef}\n data={feed.reels}\n keyExtractor={(reel) => String(reel.id)}\n renderItem={({ item, index }) => (\n <ReelItem\n reel={item}\n active={index === activeIndex && !paused}\n height={height}\n muted={muted}\n textColor={textColor}\n tracker={tracker}\n renderOverlay={renderOverlay}\n onImageShown={() => advanceFrom(index)}\n />\n )}\n getItemLayout={(_, index) => ({ length: height, offset: height * index, index })}\n pagingEnabled\n snapToInterval={height}\n snapToAlignment=\"start\"\n decelerationRate=\"fast\"\n showsVerticalScrollIndicator={false}\n onViewableItemsChanged={onViewableItemsChanged}\n viewabilityConfig={viewabilityConfig}\n onEndReached={() => void feed.loadMore()}\n onEndReachedThreshold={2}\n refreshing={feed.refreshing}\n onRefresh={() => void feed.refresh()}\n windowSize={3}\n initialNumToRender={2}\n maxToRenderPerBatch={2}\n removeClippedSubviews\n ListFooterComponent={feed.loadingMore ? <ActivityIndicator color={accent} style={styles.footer} /> : undefined}\n />\n );\n }\n\n return (\n <View style={[styles.container, { backgroundColor: background }, style]} onLayout={onLayout} testID={testID}>\n {body}\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n container: { flex: 1, overflow: \"hidden\" },\n center: { flex: 1, alignItems: \"center\", justifyContent: \"center\", padding: 24 },\n message: { fontSize: 16, textAlign: \"center\", marginBottom: 16 },\n retry: { borderWidth: 1, borderRadius: 20, paddingHorizontal: 20, paddingVertical: 10 },\n retryText: { fontSize: 15, fontWeight: \"600\" },\n footer: { paddingVertical: 24 },\n});\n","import React, { memo, useEffect, useRef, useState } from \"react\";\nimport { Image, Pressable, StyleSheet, Text, View } from \"react-native\";\nimport { ReelVideo } from \"./ReelVideo\";\nimport type { Reel } from \"@getnori/core\";\nimport type { ViewTracker } from \"./viewTracker\";\n\n/** How long an image reel stays on screen before the feed moves on (see #15). */\nexport const IMAGE_REEL_SECONDS = 15;\n\n/** The index to scroll to after `index`, or null at the end of the loaded reels. */\nexport function nextFeedIndex(index: number, count: number): number | null {\n return index + 1 < count ? index + 1 : null;\n}\n\nexport interface ReelOverlayContext {\n reel: Reel;\n /** This reel is the one on screen. */\n active: boolean;\n /** Playback position (0 for images). */\n positionMs: number;\n durationMs: number | null;\n}\n\nexport interface ReelItemProps {\n reel: Reel;\n active: boolean;\n height: number;\n muted: boolean;\n textColor: string;\n tracker: ViewTracker;\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** Called when an image reel has been on screen for IMAGE_REEL_SECONDS. */\n onImageShown?: () => void;\n}\n\nfunction ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay, onImageShown }: ReelItemProps) {\n const [userPaused, setUserPaused] = useState(false);\n const [positionMs, setPositionMs] = useState(0);\n const durationRef = useRef<number | null>(null);\n const [durationMs, setDurationMs] = useState<number | null>(null);\n const isVideo = reel.media_type === \"video\" && Boolean(reel.media_url);\n // Keep the latest callback in a ref so a parent re-render doesn't restart the image timer.\n const imageShownRef = useRef(onImageShown);\n imageShownRef.current = onImageShown;\n\n // An image reel stays on screen for IMAGE_REEL_SECONDS, then the feed moves on.\n useEffect(() => {\n if (!active || isVideo || !reel.media_url) return;\n const timer = setTimeout(() => imageShownRef.current?.(), IMAGE_REEL_SECONDS * 1000);\n return () => clearTimeout(timer);\n }, [active, isVideo, reel.media_url, reel.id]);\n\n // Becoming visible opens a view; returning to a loaded video re-opens it.\n useEffect(() => {\n if (!active) {\n setUserPaused(false);\n return;\n }\n tracker.activate(reel);\n if (durationRef.current) tracker.loaded(reel.id, durationRef.current);\n }, [active, reel, tracker]);\n\n return (\n <View style={[styles.reel, { height }]} testID={`nori-reel-${reel.id}`}>\n <Pressable\n style={StyleSheet.absoluteFill}\n onPress={() => isVideo && setUserPaused((p) => !p)}\n accessibilityRole=\"button\"\n accessibilityLabel={userPaused ? \"Play\" : \"Pause\"}\n disabled={!isVideo}\n >\n {isVideo ? (\n <ReelVideo\n testID={`nori-video-${reel.id}`}\n uri={reel.media_url as string}\n muted={muted}\n paused={!active || userPaused}\n onLoad={(seconds) => {\n const ms = seconds * 1000;\n durationRef.current = ms;\n setDurationMs(ms);\n if (active) tracker.loaded(reel.id, ms);\n }}\n onProgress={(seconds) => {\n const ms = seconds * 1000;\n setPositionMs(ms);\n if (active) tracker.progress(reel.id, ms);\n }}\n onEnd={() => active && tracker.ended(reel.id)}\n />\n ) : reel.media_url ? (\n <Image source={{ uri: reel.media_url }} style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n ) : null}\n </Pressable>\n\n {userPaused ? (\n <View pointerEvents=\"none\" style={styles.pausedBadge}>\n <Text style={styles.pausedText}>▶</Text>\n </View>\n ) : null}\n\n <View pointerEvents=\"box-none\" style={styles.caption}>\n {reel.ai_generated ? (\n <View style={styles.aiBadge} accessibilityLabel=\"AI-generated\" testID={`nori-ai-label-${reel.id}`}>\n <Text style={styles.aiText}>AI-generated</Text>\n </View>\n ) : null}\n {reel.title ? (\n <Text style={[styles.title, { color: textColor }]} numberOfLines={2}>\n {reel.title}\n </Text>\n ) : null}\n {reel.description ? (\n <Text style={[styles.description, { color: textColor }]} numberOfLines={3}>\n {reel.description}\n </Text>\n ) : null}\n </View>\n\n {renderOverlay ? (\n <View pointerEvents=\"box-none\" style={StyleSheet.absoluteFill}>\n {renderOverlay({ reel, active, positionMs, durationMs })}\n </View>\n ) : null}\n </View>\n );\n}\n\nexport const ReelItem = memo(ReelItemImpl);\n\n// react-native-video's web player ignores resizeMode and letterboxes; cover via CSS there.\n\nconst styles = StyleSheet.create({\n reel: { width: \"100%\", overflow: \"hidden\", backgroundColor: \"#000\" },\n caption: { position: \"absolute\", left: 16, right: 80, bottom: 120 },\n title: { fontSize: 17, fontWeight: \"700\", marginBottom: 4, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n description: { fontSize: 14, lineHeight: 19, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n pausedBadge: {\n position: \"absolute\", alignSelf: \"center\", top: \"45%\", width: 64, height: 64, borderRadius: 32,\n backgroundColor: \"rgba(0,0,0,0.45)\", alignItems: \"center\", justifyContent: \"center\",\n },\n pausedText: { color: \"#fff\", fontSize: 26, marginLeft: 4 },\n aiBadge: {\n alignSelf: \"flex-start\", marginBottom: 6, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10,\n backgroundColor: \"rgba(0,0,0,0.55)\", borderWidth: 1, borderColor: \"rgba(255,255,255,0.35)\",\n },\n aiText: { color: \"#fff\", fontSize: 11, fontWeight: \"700\", letterSpacing: 0.3 },\n});\n","/**\n * Web reel video: a DOM <video> rendered inside react-native-web. Bundlers pick this file for web\n * builds (Expo web, webpack, vite with react-native-web); native builds use ReelVideo.tsx.\n */\nimport React, { useEffect, useRef } from \"react\";\nimport { View } from \"react-native\";\nimport type { ReelVideoProps } from \"./ReelVideo\";\nimport { VIDEO_STYLE } from \"./videoStyle\";\n\nexport function ReelVideo({ testID, uri, muted, paused, onLoad, onProgress, onEnd }: ReelVideoProps) {\n const ref = useRef<HTMLVideoElement | null>(null);\n\n useEffect(() => {\n const video = ref.current;\n if (!video) return;\n if (paused) {\n video.pause();\n return;\n }\n // Browsers refuse autoplay with sound until the user has interacted; the muted prop\n // is how the app keeps reels playing.\n video.play()?.catch(() => undefined);\n }, [paused, uri]);\n\n // The DOM element is created inside react-native-web's View; style it like the native video.\n return (\n <View style={VIDEO_STYLE} testID={testID}>\n <video\n ref={(el) => {\n ref.current = el;\n }}\n src={uri}\n muted={muted}\n loop\n playsInline\n preload=\"auto\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n onLoadedMetadata={(e) => {\n const duration = (e.currentTarget as HTMLVideoElement).duration;\n if (Number.isFinite(duration) && duration > 0) onLoad(duration);\n }}\n onTimeUpdate={(e) => onProgress((e.currentTarget as HTMLVideoElement).currentTime)}\n onEnded={() => onEnd()}\n />\n </View>\n );\n}\n","import { Platform, StyleSheet } from \"react-native\";\n\n/** Full-bleed video. On the web the browser's object-fit does the cover fit. */\nexport const VIDEO_STYLE =\n Platform.OS === \"web\" ? [StyleSheet.absoluteFill, { objectFit: \"cover\" } as object] : StyleSheet.absoluteFill;\n","import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { NoriClient, NoriError, Reel } from \"@getnori/core\";\n\nexport interface UseFeedOptions {\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page (1-50). Default 10. */\n pageSize?: number;\n}\n\nexport interface FeedState {\n reels: Reel[];\n /** First page loading. */\n loading: boolean;\n loadingMore: boolean;\n refreshing: boolean;\n error: NoriError | Error | null;\n /** This viewer is in the holdout group: the feed is intentionally empty. */\n holdout: boolean;\n hasMore: boolean;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n}\n\n/** Pages through the viewer's feed. Reels already shown are never duplicated. */\nexport function useFeed(client: NoriClient, { tag = null, pageSize = 10 }: UseFeedOptions = {}): FeedState {\n const [reels, setReels] = useState<Reel[]>([]);\n const [loading, setLoading] = useState(true);\n const [loadingMore, setLoadingMore] = useState(false);\n const [refreshing, setRefreshing] = useState(false);\n const [error, setError] = useState<NoriError | Error | null>(null);\n const [holdout, setHoldout] = useState(false);\n const [cursor, setCursor] = useState<string | null>(null);\n const [hasMore, setHasMore] = useState(true);\n\n const busy = useRef(false);\n const generation = useRef(0); // ignores responses from before a refresh or a tag change\n const limit = Math.min(Math.max(1, Math.round(pageSize)), 50);\n\n const load = useCallback(\n async (mode: \"first\" | \"more\" | \"refresh\", fromCursor: string | null) => {\n if (busy.current && mode === \"more\") return;\n busy.current = true;\n const gen = mode === \"more\" ? generation.current : ++generation.current;\n if (mode === \"more\") setLoadingMore(true);\n if (mode === \"refresh\") setRefreshing(true);\n try {\n const page = await client.feed({ limit, cursor: fromCursor, tag });\n if (gen !== generation.current) return;\n setError(null);\n setHoldout(Boolean(page.holdout));\n setCursor(page.next_cursor ?? null);\n setHasMore(Boolean(page.next_cursor));\n setReels((prev) => {\n const base = mode === \"more\" ? prev : [];\n const seen = new Set(base.map((r) => r.id));\n return [...base, ...page.reels.filter((r) => !seen.has(r.id))];\n });\n } catch (e) {\n if (gen === generation.current) setError(e as Error);\n } finally {\n if (gen === generation.current) {\n busy.current = false;\n setLoading(false);\n setLoadingMore(false);\n setRefreshing(false);\n }\n }\n },\n [client, limit, tag],\n );\n\n useEffect(() => {\n setLoading(true);\n setReels([]);\n void load(\"first\", null);\n }, [load]);\n\n const loadMore = useCallback(async () => {\n if (!hasMore || !cursor || loading || holdout) return;\n await load(\"more\", cursor);\n }, [cursor, hasMore, holdout, load, loading]);\n\n const refresh = useCallback(() => load(\"refresh\", null), [load]);\n\n return { reels, loading, loadingMore, refreshing, error, holdout, hasMore, loadMore, refresh };\n}\n","import type { NoriClient, Reel, TrackInput } from \"@getnori/core\";\n\nexport interface ViewTrackerOptions {\n /** Report view_complete once playback reaches this share of the reel (config.rewards.min_watch_percent). */\n minWatchPercent: number;\n /** ...and the viewer has watched at least this long, counting loops (config.rewards.min_view_seconds). */\n minViewSeconds?: number;\n /** Clock in ms (tests). Default Date.now. */\n now?: () => number;\n}\n\nconst PROGRESS_MILESTONES = [25, 50, 75];\n/** A gap between progress callbacks longer than this (paused, backgrounded) doesn't count as watching. */\nconst MAX_TICK_MS = 1500;\n\ninterface View {\n reelId: number;\n durationMs: number | null;\n milestones: Set<number>;\n opened: boolean;\n furthestMs: number;\n watchedMs: number;\n lastTickAt: number | null;\n}\n\n/**\n * Turns playback callbacks into the events the API expects for one reel.\n *\n * becomes visible impression (once per reel per feed)\n * video loaded view_progress @0 (sent at once: opens the view on the server's clock)\n * 25 / 50 / 75 % view_progress\n * complete view_complete (once; the server decides the reward)\n *\n * \"Complete\" needs both conditions the server checks: the furthest position\n * reaches minWatchPercent of the reel, and the viewer has watched for at\n * least max(that share of the reel, minViewSeconds), counting loops. So a\n * 5-second reel with a 5-second minimum completes after one full play, not\n * at 80 % (which the server would refuse as \"watched too fast\").\n *\n * Images get an impression only: a reward needs a watched video.\n */\nexport class ViewTracker {\n private impressions = new Set<number>();\n private completed = new Set<number>();\n private current: View | null = null;\n private readonly now: () => number;\n\n constructor(\n private readonly track: (input: TrackInput) => void,\n private readonly options: ViewTrackerOptions,\n ) {\n this.now = options.now ?? (() => Date.now());\n }\n\n /** The reel is now the visible one. */\n activate(reel: Pick<Reel, \"id\" | \"media_type\">): void {\n if (this.current?.reelId === reel.id) return;\n this.current = {\n reelId: reel.id, durationMs: null, milestones: new Set(), opened: false,\n furthestMs: 0, watchedMs: 0, lastTickAt: null,\n };\n if (!this.impressions.has(reel.id)) {\n this.impressions.add(reel.id);\n this.track({ type: \"impression\", reel_id: reel.id });\n }\n }\n\n /** The visible video loaded and knows its duration. */\n loaded(reelId: number, durationMs: number): void {\n const view = this.view(reelId);\n if (!view || !(durationMs > 0)) return;\n view.durationMs = Math.round(durationMs);\n if (!view.opened) {\n view.opened = true;\n view.lastTickAt = this.now();\n // Sent at once: the server times the view from when this arrives.\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });\n }\n }\n\n /** Playback position of the visible video (called while it plays). */\n progress(reelId: number, positionMs: number): void {\n const view = this.view(reelId);\n if (!view || !view.durationMs || !view.opened) return;\n const now = this.now();\n if (view.lastTickAt !== null) {\n const gap = now - view.lastTickAt;\n if (gap > 0 && gap <= MAX_TICK_MS) view.watchedMs += gap;\n }\n view.lastTickAt = now;\n const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));\n view.furthestMs = Math.max(view.furthestMs, position);\n const percent = (view.furthestMs / view.durationMs) * 100;\n\n if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {\n const required = Math.max(\n (view.durationMs * this.options.minWatchPercent) / 100,\n (this.options.minViewSeconds ?? 0) * 1000,\n );\n if (view.watchedMs >= required) {\n this.completed.add(reelId);\n this.track({ type: \"view_complete\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n return;\n }\n }\n for (const milestone of PROGRESS_MILESTONES) {\n if (percent >= milestone && !view.milestones.has(milestone)) {\n view.milestones.add(milestone);\n if (!this.completed.has(reelId)) {\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n }\n }\n }\n }\n\n /** The video reached its end (it then loops). */\n ended(reelId: number): void {\n const view = this.view(reelId);\n if (view?.durationMs) this.progress(reelId, view.durationMs);\n }\n\n /** The viewer scrolled away; a return opens a new view. */\n deactivate(): void {\n this.current = null;\n }\n\n private view(reelId: number) {\n return this.current && this.current.reelId === reelId ? this.current : null;\n }\n}\n\nexport function createViewTracker(client: Pick<NoriClient, \"track\">, options: ViewTrackerOptions): ViewTracker {\n return new ViewTracker((input) => {\n try {\n client.track(input);\n } catch {\n // A disposed client must not crash playback.\n }\n }, options);\n}\n"],"mappings":";AAAA,SAAgB,eAAAA,cAAa,aAAAC,YAAW,SAAS,UAAAC,SAAQ,YAAAC,iBAAgB;AACzE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAAC;AAAA,EACA,cAAAC;AAAA,EACA,QAAAC;AAAA,EACA,QAAAC;AAAA,OAKK;;;ACbP,SAAgB,MAAM,aAAAC,YAAW,UAAAC,SAAQ,gBAAgB;AACzD,SAAS,OAAO,WAAW,cAAAC,aAAY,MAAM,QAAAC,aAAY;;;ACGzD,SAAgB,WAAW,cAAc;AACzC,SAAS,YAAY;;;ACLrB,SAAS,UAAU,kBAAkB;AAG9B,IAAM,cACX,SAAS,OAAO,QAAQ,CAAC,WAAW,cAAc,EAAE,WAAW,QAAQ,CAAW,IAAI,WAAW;;;ADuB7F;AAlBC,SAAS,UAAU,EAAE,QAAQ,KAAK,OAAO,QAAQ,QAAQ,YAAY,MAAM,GAAmB;AACnG,QAAM,MAAM,OAAgC,IAAI;AAEhD,YAAU,MAAM;AACd,UAAM,QAAQ,IAAI;AAClB,QAAI,CAAC,MAAO;AACZ,QAAI,QAAQ;AACV,YAAM,MAAM;AACZ;AAAA,IACF;AAGA,UAAM,KAAK,GAAG,MAAM,MAAM,MAAS;AAAA,EACrC,GAAG,CAAC,QAAQ,GAAG,CAAC;AAGhB,SACE,oBAAC,QAAK,OAAO,aAAa,QACxB;AAAA,IAAC;AAAA;AAAA,MACC,KAAK,CAAC,OAAO;AACX,YAAI,UAAU;AAAA,MAChB;AAAA,MACA,KAAK;AAAA,MACL;AAAA,MACA,MAAI;AAAA,MACJ,aAAW;AAAA,MACX,SAAQ;AAAA,MACR,OAAO,EAAE,OAAO,QAAQ,QAAQ,QAAQ,WAAW,QAAQ;AAAA,MAC3D,kBAAkB,CAAC,MAAM;AACvB,cAAM,WAAY,EAAE,cAAmC;AACvD,YAAI,OAAO,SAAS,QAAQ,KAAK,WAAW,EAAG,QAAO,QAAQ;AAAA,MAChE;AAAA,MACA,cAAc,CAAC,MAAM,WAAY,EAAE,cAAmC,WAAW;AAAA,MACjF,SAAS,MAAM,MAAM;AAAA;AAAA,EACvB,GACF;AAEJ;;;AD0BU,gBAAAC,MA6BJ,YA7BI;AAjEH,IAAM,qBAAqB;AAG3B,SAAS,cAAc,OAAe,OAA8B;AACzE,SAAO,QAAQ,IAAI,QAAQ,QAAQ,IAAI;AACzC;AAuBA,SAAS,aAAa,EAAE,MAAM,QAAQ,QAAQ,OAAO,WAAW,SAAS,eAAe,aAAa,GAAkB;AACrH,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,KAAK;AAClD,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,CAAC;AAC9C,QAAM,cAAcC,QAAsB,IAAI;AAC9C,QAAM,CAAC,YAAY,aAAa,IAAI,SAAwB,IAAI;AAChE,QAAM,UAAU,KAAK,eAAe,WAAW,QAAQ,KAAK,SAAS;AAErE,QAAM,gBAAgBA,QAAO,YAAY;AACzC,gBAAc,UAAU;AAGxB,EAAAC,WAAU,MAAM;AACd,QAAI,CAAC,UAAU,WAAW,CAAC,KAAK,UAAW;AAC3C,UAAM,QAAQ,WAAW,MAAM,cAAc,UAAU,GAAG,qBAAqB,GAAI;AACnF,WAAO,MAAM,aAAa,KAAK;AAAA,EACjC,GAAG,CAAC,QAAQ,SAAS,KAAK,WAAW,KAAK,EAAE,CAAC;AAG7C,EAAAA,WAAU,MAAM;AACd,QAAI,CAAC,QAAQ;AACX,oBAAc,KAAK;AACnB;AAAA,IACF;AACA,YAAQ,SAAS,IAAI;AACrB,QAAI,YAAY,QAAS,SAAQ,OAAO,KAAK,IAAI,YAAY,OAAO;AAAA,EACtE,GAAG,CAAC,QAAQ,MAAM,OAAO,CAAC;AAE1B,SACE,qBAACC,OAAA,EAAK,OAAO,CAAC,OAAO,MAAM,EAAE,OAAO,CAAC,GAAG,QAAQ,aAAa,KAAK,EAAE,IAClE;AAAA,oBAAAH;AAAA,MAAC;AAAA;AAAA,QACC,OAAOI,YAAW;AAAA,QAClB,SAAS,MAAM,WAAW,cAAc,CAAC,MAAM,CAAC,CAAC;AAAA,QACjD,mBAAkB;AAAA,QAClB,oBAAoB,aAAa,SAAS;AAAA,QAC1C,UAAU,CAAC;AAAA,QAEV,oBACC,gBAAAJ;AAAA,UAAC;AAAA;AAAA,YACC,QAAQ,cAAc,KAAK,EAAE;AAAA,YAC7B,KAAK,KAAK;AAAA,YACV;AAAA,YACA,QAAQ,CAAC,UAAU;AAAA,YACnB,QAAQ,CAAC,YAAY;AACnB,oBAAM,KAAK,UAAU;AACrB,0BAAY,UAAU;AACtB,4BAAc,EAAE;AAChB,kBAAI,OAAQ,SAAQ,OAAO,KAAK,IAAI,EAAE;AAAA,YACxC;AAAA,YACA,YAAY,CAAC,YAAY;AACvB,oBAAM,KAAK,UAAU;AACrB,4BAAc,EAAE;AAChB,kBAAI,OAAQ,SAAQ,SAAS,KAAK,IAAI,EAAE;AAAA,YAC1C;AAAA,YACA,OAAO,MAAM,UAAU,QAAQ,MAAM,KAAK,EAAE;AAAA;AAAA,QAC9C,IACE,KAAK,YACP,gBAAAA,KAAC,SAAM,QAAQ,EAAE,KAAK,KAAK,UAAU,GAAG,OAAOI,YAAW,cAAc,YAAW,SAAQ,IACzF;AAAA;AAAA,IACN;AAAA,IAEC,aACC,gBAAAJ,KAACG,OAAA,EAAK,eAAc,QAAO,OAAO,OAAO,aACvC,0BAAAH,KAAC,QAAK,OAAO,OAAO,YAAY,oBAAC,GACnC,IACE;AAAA,IAEJ,qBAACG,OAAA,EAAK,eAAc,YAAW,OAAO,OAAO,SAC1C;AAAA,WAAK,eACJ,gBAAAH,KAACG,OAAA,EAAK,OAAO,OAAO,SAAS,oBAAmB,gBAAe,QAAQ,iBAAiB,KAAK,EAAE,IAC7F,0BAAAH,KAAC,QAAK,OAAO,OAAO,QAAQ,0BAAY,GAC1C,IACE;AAAA,MACH,KAAK,QACJ,gBAAAA,KAAC,QAAK,OAAO,CAAC,OAAO,OAAO,EAAE,OAAO,UAAU,CAAC,GAAG,eAAe,GAC/D,eAAK,OACR,IACE;AAAA,MACH,KAAK,cACJ,gBAAAA,KAAC,QAAK,OAAO,CAAC,OAAO,aAAa,EAAE,OAAO,UAAU,CAAC,GAAG,eAAe,GACrE,eAAK,aACR,IACE;AAAA,OACN;AAAA,IAEC,gBACC,gBAAAA,KAACG,OAAA,EAAK,eAAc,YAAW,OAAOC,YAAW,cAC9C,wBAAc,EAAE,MAAM,QAAQ,YAAY,WAAW,CAAC,GACzD,IACE;AAAA,KACN;AAEJ;AAEO,IAAM,WAAW,KAAK,YAAY;AAIzC,IAAM,SAASA,YAAW,OAAO;AAAA,EAC/B,MAAM,EAAE,OAAO,QAAQ,UAAU,UAAU,iBAAiB,OAAO;AAAA,EACnE,SAAS,EAAE,UAAU,YAAY,MAAM,IAAI,OAAO,IAAI,QAAQ,IAAI;AAAA,EAClE,OAAO,EAAE,UAAU,IAAI,YAAY,OAAO,cAAc,GAAG,iBAAiB,mBAAmB,kBAAkB,EAAE;AAAA,EACnH,aAAa,EAAE,UAAU,IAAI,YAAY,IAAI,iBAAiB,mBAAmB,kBAAkB,EAAE;AAAA,EACrG,aAAa;AAAA,IACX,UAAU;AAAA,IAAY,WAAW;AAAA,IAAU,KAAK;AAAA,IAAO,OAAO;AAAA,IAAI,QAAQ;AAAA,IAAI,cAAc;AAAA,IAC5F,iBAAiB;AAAA,IAAoB,YAAY;AAAA,IAAU,gBAAgB;AAAA,EAC7E;AAAA,EACA,YAAY,EAAE,OAAO,QAAQ,UAAU,IAAI,YAAY,EAAE;AAAA,EACzD,SAAS;AAAA,IACP,WAAW;AAAA,IAAc,cAAc;AAAA,IAAG,mBAAmB;AAAA,IAAG,iBAAiB;AAAA,IAAG,cAAc;AAAA,IAClG,iBAAiB;AAAA,IAAoB,aAAa;AAAA,IAAG,aAAa;AAAA,EACpE;AAAA,EACA,QAAQ,EAAE,OAAO,QAAQ,UAAU,IAAI,YAAY,OAAO,eAAe,IAAI;AAC/E,CAAC;;;AGnJD,SAAS,aAAa,aAAAC,YAAW,UAAAC,SAAQ,YAAAC,iBAAgB;AAyBlD,SAAS,QAAQ,QAAoB,EAAE,MAAM,MAAM,WAAW,GAAG,IAAoB,CAAC,GAAc;AACzG,QAAM,CAAC,OAAO,QAAQ,IAAIA,UAAiB,CAAC,CAAC;AAC7C,QAAM,CAAC,SAAS,UAAU,IAAIA,UAAS,IAAI;AAC3C,QAAM,CAAC,aAAa,cAAc,IAAIA,UAAS,KAAK;AACpD,QAAM,CAAC,YAAY,aAAa,IAAIA,UAAS,KAAK;AAClD,QAAM,CAAC,OAAO,QAAQ,IAAIA,UAAmC,IAAI;AACjE,QAAM,CAAC,SAAS,UAAU,IAAIA,UAAS,KAAK;AAC5C,QAAM,CAAC,QAAQ,SAAS,IAAIA,UAAwB,IAAI;AACxD,QAAM,CAAC,SAAS,UAAU,IAAIA,UAAS,IAAI;AAE3C,QAAM,OAAOD,QAAO,KAAK;AACzB,QAAM,aAAaA,QAAO,CAAC;AAC3B,QAAM,QAAQ,KAAK,IAAI,KAAK,IAAI,GAAG,KAAK,MAAM,QAAQ,CAAC,GAAG,EAAE;AAE5D,QAAM,OAAO;AAAA,IACX,OAAO,MAAoC,eAA8B;AACvE,UAAI,KAAK,WAAW,SAAS,OAAQ;AACrC,WAAK,UAAU;AACf,YAAM,MAAM,SAAS,SAAS,WAAW,UAAU,EAAE,WAAW;AAChE,UAAI,SAAS,OAAQ,gBAAe,IAAI;AACxC,UAAI,SAAS,UAAW,eAAc,IAAI;AAC1C,UAAI;AACF,cAAM,OAAO,MAAM,OAAO,KAAK,EAAE,OAAO,QAAQ,YAAY,IAAI,CAAC;AACjE,YAAI,QAAQ,WAAW,QAAS;AAChC,iBAAS,IAAI;AACb,mBAAW,QAAQ,KAAK,OAAO,CAAC;AAChC,kBAAU,KAAK,eAAe,IAAI;AAClC,mBAAW,QAAQ,KAAK,WAAW,CAAC;AACpC,iBAAS,CAAC,SAAS;AACjB,gBAAM,OAAO,SAAS,SAAS,OAAO,CAAC;AACvC,gBAAM,OAAO,IAAI,IAAI,KAAK,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AAC1C,iBAAO,CAAC,GAAG,MAAM,GAAG,KAAK,MAAM,OAAO,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC,CAAC;AAAA,QAC/D,CAAC;AAAA,MACH,SAAS,GAAG;AACV,YAAI,QAAQ,WAAW,QAAS,UAAS,CAAU;AAAA,MACrD,UAAE;AACA,YAAI,QAAQ,WAAW,SAAS;AAC9B,eAAK,UAAU;AACf,qBAAW,KAAK;AAChB,yBAAe,KAAK;AACpB,wBAAc,KAAK;AAAA,QACrB;AAAA,MACF;AAAA,IACF;AAAA,IACA,CAAC,QAAQ,OAAO,GAAG;AAAA,EACrB;AAEA,EAAAD,WAAU,MAAM;AACd,eAAW,IAAI;AACf,aAAS,CAAC,CAAC;AACX,SAAK,KAAK,SAAS,IAAI;AAAA,EACzB,GAAG,CAAC,IAAI,CAAC;AAET,QAAM,WAAW,YAAY,YAAY;AACvC,QAAI,CAAC,WAAW,CAAC,UAAU,WAAW,QAAS;AAC/C,UAAM,KAAK,QAAQ,MAAM;AAAA,EAC3B,GAAG,CAAC,QAAQ,SAAS,SAAS,MAAM,OAAO,CAAC;AAE5C,QAAM,UAAU,YAAY,MAAM,KAAK,WAAW,IAAI,GAAG,CAAC,IAAI,CAAC;AAE/D,SAAO,EAAE,OAAO,SAAS,aAAa,YAAY,OAAO,SAAS,SAAS,UAAU,QAAQ;AAC/F;;;AC3EA,IAAM,sBAAsB,CAAC,IAAI,IAAI,EAAE;AAEvC,IAAM,cAAc;AA4Bb,IAAM,cAAN,MAAkB;AAAA,EAMvB,YACmB,OACA,SACjB;AAFiB;AACA;AAPnB,SAAQ,cAAc,oBAAI,IAAY;AACtC,SAAQ,YAAY,oBAAI,IAAY;AACpC,SAAQ,UAAuB;AAO7B,SAAK,MAAM,QAAQ,QAAQ,MAAM,KAAK,IAAI;AAAA,EAC5C;AAAA;AAAA,EAGA,SAAS,MAA6C;AACpD,QAAI,KAAK,SAAS,WAAW,KAAK,GAAI;AACtC,SAAK,UAAU;AAAA,MACb,QAAQ,KAAK;AAAA,MAAI,YAAY;AAAA,MAAM,YAAY,oBAAI,IAAI;AAAA,MAAG,QAAQ;AAAA,MAClE,YAAY;AAAA,MAAG,WAAW;AAAA,MAAG,YAAY;AAAA,IAC3C;AACA,QAAI,CAAC,KAAK,YAAY,IAAI,KAAK,EAAE,GAAG;AAClC,WAAK,YAAY,IAAI,KAAK,EAAE;AAC5B,WAAK,MAAM,EAAE,MAAM,cAAc,SAAS,KAAK,GAAG,CAAC;AAAA,IACrD;AAAA,EACF;AAAA;AAAA,EAGA,OAAO,QAAgB,YAA0B;AAC/C,UAAM,OAAO,KAAK,KAAK,MAAM;AAC7B,QAAI,CAAC,QAAQ,EAAE,aAAa,GAAI;AAChC,SAAK,aAAa,KAAK,MAAM,UAAU;AACvC,QAAI,CAAC,KAAK,QAAQ;AAChB,WAAK,SAAS;AACd,WAAK,aAAa,KAAK,IAAI;AAE3B,WAAK,MAAM,EAAE,MAAM,iBAAiB,SAAS,QAAQ,aAAa,GAAG,aAAa,KAAK,YAAY,WAAW,KAAK,CAAC;AAAA,IACtH;AAAA,EACF;AAAA;AAAA,EAGA,SAAS,QAAgB,YAA0B;AACjD,UAAM,OAAO,KAAK,KAAK,MAAM;AAC7B,QAAI,CAAC,QAAQ,CAAC,KAAK,cAAc,CAAC,KAAK,OAAQ;AAC/C,UAAM,MAAM,KAAK,IAAI;AACrB,QAAI,KAAK,eAAe,MAAM;AAC5B,YAAM,MAAM,MAAM,KAAK;AACvB,UAAI,MAAM,KAAK,OAAO,YAAa,MAAK,aAAa;AAAA,IACvD;AACA,SAAK,aAAa;AAClB,UAAM,WAAW,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,MAAM,UAAU,GAAG,KAAK,UAAU,CAAC;AAC9E,SAAK,aAAa,KAAK,IAAI,KAAK,YAAY,QAAQ;AACpD,UAAM,UAAW,KAAK,aAAa,KAAK,aAAc;AAEtD,QAAI,CAAC,KAAK,UAAU,IAAI,MAAM,KAAK,WAAW,KAAK,QAAQ,iBAAiB;AAC1E,YAAM,WAAW,KAAK;AAAA,QACnB,KAAK,aAAa,KAAK,QAAQ,kBAAmB;AAAA,SAClD,KAAK,QAAQ,kBAAkB,KAAK;AAAA,MACvC;AACA,UAAI,KAAK,aAAa,UAAU;AAC9B,aAAK,UAAU,IAAI,MAAM;AACzB,aAAK,MAAM,EAAE,MAAM,iBAAiB,SAAS,QAAQ,aAAa,KAAK,YAAY,aAAa,KAAK,WAAW,CAAC;AACjH;AAAA,MACF;AAAA,IACF;AACA,eAAW,aAAa,qBAAqB;AAC3C,UAAI,WAAW,aAAa,CAAC,KAAK,WAAW,IAAI,SAAS,GAAG;AAC3D,aAAK,WAAW,IAAI,SAAS;AAC7B,YAAI,CAAC,KAAK,UAAU,IAAI,MAAM,GAAG;AAC/B,eAAK,MAAM,EAAE,MAAM,iBAAiB,SAAS,QAAQ,aAAa,KAAK,YAAY,aAAa,KAAK,WAAW,CAAC;AAAA,QACnH;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA,EAGA,MAAM,QAAsB;AAC1B,UAAM,OAAO,KAAK,KAAK,MAAM;AAC7B,QAAI,MAAM,WAAY,MAAK,SAAS,QAAQ,KAAK,UAAU;AAAA,EAC7D;AAAA;AAAA,EAGA,aAAmB;AACjB,SAAK,UAAU;AAAA,EACjB;AAAA,EAEQ,KAAK,QAAgB;AAC3B,WAAO,KAAK,WAAW,KAAK,QAAQ,WAAW,SAAS,KAAK,UAAU;AAAA,EACzE;AACF;AAEO,SAAS,kBAAkB,QAAmC,SAA0C;AAC7G,SAAO,IAAI,YAAY,CAAC,UAAU;AAChC,QAAI;AACF,aAAO,MAAM,KAAK;AAAA,IACpB,QAAQ;AAAA,IAER;AAAA,EACF,GAAG,OAAO;AACZ;;;ALLW,gBAAAG,MAKL,QAAAC,aALK;AAlFX,IAAM,4BAA4B;AAClC,IAAM,2BAA2B;AAG1B,SAAS,SAAS;AAAA,EACvB;AAAA,EACA,MAAM;AAAA,EACN,WAAW;AAAA,EACX,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR,SAAS;AAAA,EACT;AAAA,EACA,SAAS;AACX,GAAkB;AAChB,QAAM,OAAO,QAAQ,QAAQ,EAAE,KAAK,SAAS,CAAC;AAC9C,QAAM,CAAC,QAAQ,SAAS,IAAIC,UAAwB,IAAI;AACxD,QAAM,CAAC,gBAAgB,SAAS,IAAIA,UAAS,CAAC;AAC9C,QAAM,SAAS,eAAe;AAC9B,QAAM,CAAC,aAAa,cAAc,IAAIA,UAAS,CAAC;AAEhD,EAAAC,WAAU,MAAM;AACd,QAAI,OAAO;AACX,WAAO,OAAO,EAAE;AAAA,MACd,CAAC,MAAM,QAAQ,UAAU,CAAC;AAAA,MAC1B,MAAM;AAAA;AAAA,IACR;AACA,WAAO,MAAM;AACX,aAAO;AAAA,IACT;AAAA,EACF,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,kBAAkB,QAAQ,QAAQ,qBAAqB;AAC7D,QAAM,iBAAiB,QAAQ,QAAQ,oBAAoB;AAC3D,QAAM,UAAU;AAAA,IACd,MAAM,kBAAkB,QAAQ,EAAE,iBAAiB,eAAe,CAAC;AAAA,IACnE,CAAC,QAAQ,iBAAiB,cAAc;AAAA,EAC1C;AAGA,EAAAA,WAAU,MAAM;AACd,UAAM,MAAM,SAAS,iBAAiB,UAAU,CAAC,UAAU;AACzD,UAAI,UAAU,SAAU,MAAK,OAAO,MAAM,EAAE,MAAM,MAAM,MAAS;AAAA,IACnE,CAAC;AACD,WAAO,MAAM,IAAI,OAAO;AAAA,EAC1B,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,kBAAkBC,QAAO,YAAY;AAC3C,kBAAgB,UAAU;AAC1B,QAAM,WAAWA,QAAO,KAAK,KAAK;AAClC,WAAS,UAAU,KAAK;AAExB,QAAM,UAAUA,QAAuB,IAAI;AAC3C,QAAM,cAAcC,aAAY,CAAC,UAAkB;AACjD,UAAM,OAAO,cAAc,OAAO,SAAS,QAAQ,MAAM;AACzD,QAAI,QAAQ,KAAM,SAAQ,SAAS,cAAc,EAAE,OAAO,MAAM,UAAU,KAAK,CAAC;AAAA,EAClF,GAAG,CAAC,CAAC;AAEL,QAAM,yBAAyBD,QAAmE,CAAC,EAAE,cAAc,MAAM;AACvH,UAAM,QAAQ,cAAc,KAAK,CAAC,MAAM,EAAE,cAAc,EAAE,UAAU,IAAI;AACxE,QAAI,OAAO,SAAS,KAAM;AAC1B,mBAAe,MAAM,KAAK;AAC1B,UAAM,OAAO,SAAS,QAAQ,MAAM,KAAK;AACzC,QAAI,KAAM,iBAAgB,UAAU,MAAM,MAAM,KAAK;AAAA,EACvD,CAAC,EAAE;AACH,QAAM,oBAAoBA,QAAO,EAAE,6BAA6B,GAAG,CAAC,EAAE;AAEtE,QAAM,WAAWC,aAAY,CAAC,MAAyB;AACrD,UAAM,IAAI,KAAK,MAAM,EAAE,YAAY,OAAO,MAAM;AAChD,QAAI,IAAI,EAAG,WAAU,CAAC;AAAA,EACxB,GAAG,CAAC,CAAC;AAEL,QAAM,QAAQ,QAAQ,SAAS,CAAC;AAChC,QAAM,aAAa,MAAM,oBAAoB;AAC7C,QAAM,YAAY,MAAM,cAAc;AACtC,QAAM,SAAS,MAAM,iBAAiB;AAEtC,MAAI;AACJ,MAAI,KAAK,WAAW,KAAK,MAAM,WAAW,GAAG;AAC3C,WAAO,gBAAAL,KAAC,qBAAkB,OAAO,QAAQ,OAAOM,QAAO,QAAQ,QAAQ,GAAG,MAAM,YAAY;AAAA,EAC9F,WAAW,KAAK,SAAS;AACvB,WAAO,gBAAgB,cAAc,IAAI;AAAA,EAC3C,WAAW,KAAK,SAAS,KAAK,MAAM,WAAW,GAAG;AAChD,WACE,gBAAAL,MAACM,OAAA,EAAK,OAAOD,QAAO,QAAQ,QAAQ,GAAG,MAAM,UAC3C;AAAA,sBAAAN,KAACQ,OAAA,EAAK,OAAO,CAACF,QAAO,SAAS,EAAE,OAAO,UAAU,CAAC,GAAG,qCAAuB;AAAA,MAC5E,gBAAAN,KAACS,YAAA,EAAU,SAAS,MAAM,KAAK,KAAK,QAAQ,GAAG,mBAAkB,UAAS,OAAO,CAACH,QAAO,OAAO,EAAE,aAAa,OAAO,CAAC,GACrH,0BAAAN,KAACQ,OAAA,EAAK,OAAO,CAACF,QAAO,WAAW,EAAE,OAAO,OAAO,CAAC,GAAG,uBAAS,GAC/D;AAAA,OACF;AAAA,EAEJ,WAAW,KAAK,MAAM,WAAW,GAAG;AAClC,WAAO,cAAc,YAAY,IAC/B,gBAAAN,KAACO,OAAA,EAAK,OAAOD,QAAO,QAAQ,QAAQ,GAAG,MAAM,UAC3C,0BAAAN,KAACQ,OAAA,EAAK,OAAO,CAACF,QAAO,SAAS,EAAE,OAAO,UAAU,CAAC,GAAG,+BAAiB,GACxE;AAAA,EAEJ,WAAW,SAAS,GAAG;AACrB,WACE,gBAAAN;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,MAAM,KAAK;AAAA,QACX,cAAc,CAAC,SAAS,OAAO,KAAK,EAAE;AAAA,QACtC,YAAY,CAAC,EAAE,MAAM,MAAM,MACzB,gBAAAA;AAAA,UAAC;AAAA;AAAA,YACC,MAAM;AAAA,YACN,QAAQ,UAAU,eAAe,CAAC;AAAA,YAClC;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA,cAAc,MAAM,YAAY,KAAK;AAAA;AAAA,QACvC;AAAA,QAEF,eAAe,CAAC,GAAG,WAAW,EAAE,QAAQ,QAAQ,QAAQ,SAAS,OAAO,MAAM;AAAA,QAC9E,eAAa;AAAA,QACb,gBAAgB;AAAA,QAChB,iBAAgB;AAAA,QAChB,kBAAiB;AAAA,QACjB,8BAA8B;AAAA,QAC9B;AAAA,QACA;AAAA,QACA,cAAc,MAAM,KAAK,KAAK,SAAS;AAAA,QACvC,uBAAuB;AAAA,QACvB,YAAY,KAAK;AAAA,QACjB,WAAW,MAAM,KAAK,KAAK,QAAQ;AAAA,QACnC,YAAY;AAAA,QACZ,oBAAoB;AAAA,QACpB,qBAAqB;AAAA,QACrB,uBAAqB;AAAA,QACrB,qBAAqB,KAAK,cAAc,gBAAAA,KAAC,qBAAkB,OAAO,QAAQ,OAAOM,QAAO,QAAQ,IAAK;AAAA;AAAA,IACvG;AAAA,EAEJ;AAEA,SACE,gBAAAN,KAACO,OAAA,EAAK,OAAO,CAACD,QAAO,WAAW,EAAE,iBAAiB,WAAW,GAAG,KAAK,GAAG,UAAoB,QAC1F,gBACH;AAEJ;AAEA,IAAMA,UAASI,YAAW,OAAO;AAAA,EAC/B,WAAW,EAAE,MAAM,GAAG,UAAU,SAAS;AAAA,EACzC,QAAQ,EAAE,MAAM,GAAG,YAAY,UAAU,gBAAgB,UAAU,SAAS,GAAG;AAAA,EAC/E,SAAS,EAAE,UAAU,IAAI,WAAW,UAAU,cAAc,GAAG;AAAA,EAC/D,OAAO,EAAE,aAAa,GAAG,cAAc,IAAI,mBAAmB,IAAI,iBAAiB,GAAG;AAAA,EACtF,WAAW,EAAE,UAAU,IAAI,YAAY,MAAM;AAAA,EAC7C,QAAQ,EAAE,iBAAiB,GAAG;AAChC,CAAC;","names":["useCallback","useEffect","useRef","useState","Pressable","StyleSheet","Text","View","useEffect","useRef","StyleSheet","View","jsx","useRef","useEffect","View","StyleSheet","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.3.0",
3
+ "version": "0.3.1",
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": {
@@ -36,7 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@getnori/core": "^0.3.0"
39
+ "@getnori/core": "^0.3.1"
40
40
  },
41
41
  "devDependencies": {
42
42
  "react": "19.2.3",