From ba57070271a6b0dc13fe20652ddec13eff742167 Mon Sep 17 00:00:00 2001 From: Jade Date: Thu, 22 Jan 2026 17:02:22 +0000 Subject: [PATCH] feat: release --- MainSDK.js | 510 +++++++++- components/Stories/ProductsCarousel.js | 231 +++++ components/Stories/StoriesList.js | 364 +++++++ components/Stories/StoryElements.js | 607 ++++++++++++ components/Stories/StorySlide.js | 734 ++++++++++++++ components/Stories/StoryTimeline.js | 345 +++++++ components/Stories/StoryViewer.js | 1224 ++++++++++++++++++++++++ components/Stories/styles.js | 895 +++++++++++++++++ lib/client.js | 17 +- lib/stories/cacheManager.js | 176 ++++ lib/stories/imageCache.js | 190 ++++ lib/stories/slidePreloader.js | 576 +++++++++++ lib/stories/storage.js | 187 ++++ lib/stories/types.js | 136 +++ lib/stories/videoCache.js | 588 ++++++++++++ lib/tests/tracker.test.js | 2 +- lib/tests/utils.test.js | 4 +- tests/search.test.js | 2 +- 18 files changed, 6750 insertions(+), 38 deletions(-) create mode 100644 components/Stories/ProductsCarousel.js create mode 100644 components/Stories/StoriesList.js create mode 100644 components/Stories/StoryElements.js create mode 100644 components/Stories/StorySlide.js create mode 100644 components/Stories/StoryTimeline.js create mode 100644 components/Stories/StoryViewer.js create mode 100644 components/Stories/styles.js create mode 100644 lib/stories/cacheManager.js create mode 100644 lib/stories/imageCache.js create mode 100644 lib/stories/slidePreloader.js create mode 100644 lib/stories/storage.js create mode 100644 lib/stories/types.js create mode 100644 lib/stories/videoCache.js diff --git a/MainSDK.js b/MainSDK.js index efb0013..c255390 100644 --- a/MainSDK.js +++ b/MainSDK.js @@ -14,10 +14,12 @@ import { getLastPushTokenSentDate } from './lib/client' import { saveLastPushTokenSentDate } from './lib/client' import { convertParams } from './lib/tracker' import { NotificationManager } from './lib/notification' +import AsyncStorage from '@react-native-async-storage/async-storage' import { PermissionsAndroid } from 'react-native' import { Platform } from 'react-native' import { getMessaging } from '@react-native-firebase/messaging' import { onMessage } from '@react-native-firebase/messaging' +import firebase from '@react-native-firebase/app' import { setBackgroundMessageHandler } from '@react-native-firebase/messaging' import { getToken } from '@react-native-firebase/messaging' import { getAPNSToken } from '@react-native-firebase/messaging' @@ -32,6 +34,8 @@ import { SDK_PUSH_CHANNEL } from './index' import Performer from './lib/performer' import { blankSearchRequest } from './utils' import { isOverOneWeekAgo } from './utils' +import { getStorageKey } from './utils' +import { SDK_API_URL } from './index' /** * @typedef {Object} Event @@ -96,14 +100,21 @@ class MainSDK extends Performer { super(queue) this.shop_id = shop_id this.stream = stream ?? null + this.deviceId = '' + this.userSeance = '' + this.segment = '' this.initialized = false DEBUG = debug this._push_type = null this.push_payload = [] this.lastMessageIds = [] this.autoSendPushToken = autoSendPushToken - this.messaging = getMessaging() this.deviceInfo = deviceInfo + + // Firebase is initialized automatically by native modules + // Initialize messaging lazily when needed + this.messaging = null + this._initMessaging() } /** @@ -114,29 +125,91 @@ class MainSDK extends Performer { command() } + async initializeSegment() { + const key = getStorageKey('segment', this.shop_id) + const segments = ['A', 'B'] + + try { + const stored = await AsyncStorage.getItem(key) + if (stored && segments.includes(stored)) { + this.segment = stored + return stored + } + + const generated = segments[Math.round(Math.random())] + this.segment = generated + await AsyncStorage.setItem(key, generated) + return generated + } catch (error) { + const generated = segments[Math.round(Math.random())] + this.segment = generated + return generated + } + } + + _initMessaging() { + // Initialize Firebase messaging lazily + // Firebase is initialized automatically by native modules in React Native + // getMessaging() can be called directly without checking firebase.apps + try { + // Check if Firebase is available before initializing messaging + if (firebase.apps && firebase.apps.length > 0) { + this.messaging = getMessaging() + } else { + // Firebase not initialized - this is OK for SDK features that don't require push + this.messaging = null + if (DEBUG) console.log('Firebase not initialized - push features will be disabled') + } + } catch (error) { + console.warn('Firebase messaging initialization failed:', error) + this.messaging = null + } + } + + _ensureMessaging() { + if (!this.messaging) { + this._initMessaging() + } + return this.messaging + } + /** * @returns {void} */ init() { ;(async () => { try { + if (DEBUG) console.log('[SDK Init] Starting initialization...') + if (!this.shop_id || typeof this.shop_id !== 'string') { const initError = new Error( 'Parameter "shop_id" is required as a string.' ) initError.name = 'Init error' + if (DEBUG) console.error('[SDK Init] Error:', initError) throw initError } if (this.stream && typeof this.stream !== 'string') { const streamError = new Error('Parameter "stream" must be a string.') streamError.name = 'Init error' + if (DEBUG) console.error('[SDK Init] Error:', streamError) throw streamError } + + if (DEBUG) console.log('[SDK Init] Shop ID:', this.shop_id, 'Stream:', this.stream) + + // JS SDK behavior: initialize segment before init request + await this.initializeSegment() + const storageData = await getData(this.shop_id) + if (DEBUG) console.log('[SDK Init] Storage data:', storageData) + let response = null if (storageData?.did) { + if (DEBUG) console.log('[SDK Init] Using cached device ID:', storageData.did) + this.deviceId = storageData.did response = storageData if ( !storageData?.seance || @@ -144,9 +217,11 @@ class MainSDK extends Performer { new Date().getTime() > storageData?.expires ) { response.sid = response.seance = generateSid() + if (DEBUG) console.log('[SDK Init] Generated new session ID:', response.sid) } } else { - let did + if (DEBUG) console.log('[SDK Init] Making init request to API...') + let did = '' if (this.deviceInfo && this.deviceInfo.id) { did = this.deviceInfo.id @@ -159,33 +234,74 @@ class MainSDK extends Performer { : (await DeviceInfo.syncUniqueId()) || '' } catch (e) { console.error( - `Device ID is not present in init args, but also 'react-native-device-info' is not present: ${JSON.stringify(e, undefined, 2)}` + `Device ID is not present in init args, but also 'react-native-device-info' is not present: ${JSON.stringify( + e, + undefined, + 2 + )}` ) did = '' } } - response = await request('init', this.shop_id, { - params: { - did, - shop_id: this.shop_id, - stream: this.stream, - }, - }) - } + const params = { + shop_id: this.shop_id, + stream: this.stream, + } + if (did) { + params.did = did + } - updSeance(this.shop_id, response?.did, response?.seance).then( - async () => { - this.initialized = true - this.performQueue() - this.initPushChannelAndToken() - if (this.isInit() && this.autoSendPushToken) { - await this.sendPushToken() - } + response = await request('init', this.shop_id, { params }) + + if (DEBUG) console.log('[SDK Init] API response:', response) + + // Check if response is an error + if (response instanceof Error || (response && response.message)) { + const error = response instanceof Error ? response : new Error(response.message || 'Init request failed') + if (DEBUG) console.error('[SDK Init] API error:', error) + this.initialized = false + throw error } - ) + } + + if (!response || (!response.did && !storageData?.did)) { + const error = new Error('Invalid response from init: missing device ID') + if (DEBUG) console.error('[SDK Init] Error:', error, 'Response:', response) + this.initialized = false + throw error + } + + const didToUse = response?.did || storageData?.did || '' + this.deviceId = didToUse + this.userSeance = response?.seance || response?.sid || '' + if (!this.segment && response?.segment) { + this.segment = response.segment + } + + if (DEBUG) console.log('[SDK Init] Updating session...') + await updSeance(this.shop_id, didToUse, response?.seance) + + this.initialized = true + if (DEBUG) console.log('[SDK Init] SDK initialized successfully!') + + // Initialize messaging after SDK is initialized + this._initMessaging() + this.performQueue() + this.initPushChannelAndToken() + if (this.isInit() && this.autoSendPushToken) { + await this.sendPushToken() + } } catch (error) { this.initialized = false + console.error('[SDK Init] Initialization failed:', error) + if (DEBUG) { + console.error('[SDK Init] Error details:', { + message: error.message, + stack: error.stack, + name: error.name + }) + } return error } })() @@ -196,6 +312,21 @@ class MainSDK extends Performer { */ isInit = () => this.initialized + /** + * Gets the current device ID, ensuring it's synchronized with storage + * @returns {Promise} The device ID + */ + async getDeviceId() { + // Always get the latest deviceId from storage to ensure synchronization + const storageData = await getData(this.shop_id) + const deviceId = storageData?.did || this.deviceId || '' + // Update instance variable for consistency + if (deviceId && deviceId !== this.deviceId) { + this.deviceId = deviceId + } + return deviceId + } + /** * @returns {Promise} */ @@ -353,6 +484,302 @@ class MainSDK extends Performer { }) } + /** + * Fetch stories data for a given code + * @param {string} code - Stories code identifier + * @returns {Promise} - Promise that resolves with stories data + */ + getStories(code) { + return new Promise((resolve, reject) => { + this.push(async () => { + try { + // Get current deviceId from storage to ensure it's up-to-date + const storageData = await getData(this.shop_id) + const deviceId = storageData?.did || this.deviceId || '' + if (deviceId && deviceId !== this.deviceId) { + this.deviceId = deviceId + } + + const requestParams = { + shop_id: this.shop_id, + did: deviceId, + } + + console.log('[getStories] Making request with params:', { + url: `stories/${code}`, + shop_id: this.shop_id, + did: deviceId, + code: code, + fullUrl: `${SDK_API_URL}stories/${code}` + }) + + request(`stories/${code}`, this.shop_id, { + params: requestParams, + }).then((res) => { + // Transform snake_case to camelCase for backgroundColor and elements + if (res?.stories) { + res.stories.forEach(story => { + if (story.slides) { + story.slides.forEach(slide => { + // Convert background_color to backgroundColor + if (slide.background_color && !slide.backgroundColor) { + slide.backgroundColor = slide.background_color + } + + // Parse duration (convert to number if string, match iOS SDK: Int in seconds, default 10) + if (slide.duration !== undefined) { + slide.duration = typeof slide.duration === 'string' ? parseInt(slide.duration, 10) || 10 : slide.duration + } else { + // If duration is missing, set default 10 seconds (like iOS SDK) + slide.duration = 10 + } + + // Convert snake_case to camelCase for elements + if (slide.elements && Array.isArray(slide.elements)) { + slide.elements.forEach(element => { + // text_input -> textInput + if (element.text_input !== undefined && element.textInput === undefined) { + element.textInput = element.text_input + } + // text_color -> textColor + if (element.text_color !== undefined && element.textColor === undefined) { + element.textColor = element.text_color + } + // text_background_color -> textBackgroundColor + if (element.text_background_color !== undefined && element.textBackgroundColor === undefined) { + element.textBackgroundColor = element.text_background_color + } + // text_background_color_opacity -> textBackgroundColorOpacity + if (element.text_background_color_opacity !== undefined && element.textBackgroundColorOpacity === undefined) { + element.textBackgroundColorOpacity = element.text_background_color_opacity + } + // text_align -> textAlignment + if (element.text_align !== undefined && element.textAlignment === undefined) { + element.textAlignment = element.text_align + } + // text_line_spacing -> textLineSpacing (convert to number) + if (element.text_line_spacing !== undefined && element.textLineSpacing === undefined) { + const spacing = element.text_line_spacing + element.textLineSpacing = typeof spacing === 'string' ? parseFloat(spacing) || 0 : spacing + } + // text_bold -> textBold + if (element.text_bold !== undefined && element.textBold === undefined) { + element.textBold = element.text_bold + } + // font_size -> fontSize (convert to number) + if (element.font_size !== undefined && element.fontSize === undefined) { + const size = element.font_size + element.fontSize = typeof size === 'string' ? parseFloat(size) || undefined : size + } + // font_type -> fontType + if (element.font_type !== undefined && element.fontType === undefined) { + element.fontType = element.font_type + } + // y_offset -> yOffset (convert to number) + if (element.y_offset !== undefined && element.yOffset === undefined) { + const offset = element.y_offset + element.yOffset = typeof offset === 'string' ? parseFloat(offset) || 0 : offset + } + }) + } + }) + } + }) + } + + console.log('[getStories] API response received:', { + status: res?.status, + hasStories: !!res?.stories, + storiesCount: res?.stories?.length || 0, + stories: res?.stories?.map((story, idx) => ({ + index: idx, + id: story.id, + name: story.name, + avatar: story.avatar, + slidesCount: story.slides?.length || 0, + slides: story.slides?.map((slide, slideIdx) => ({ + slideIndex: slideIdx, + id: slide.id, + background: slide.background, + backgroundColor: slide.backgroundColor, + background_color: slide.background_color, + type: slide.type, + elementsCount: slide.elements?.length || 0, + elements: slide.elements?.map(el => ({ + type: el.type, + title: el.title, + textInput: el.textInput + })) + })) + })) + }) + + // Check for duplicate stories + if (res?.stories && res.stories.length > 0) { + const storyIds = res.stories.map(s => s.id) + const uniqueIds = new Set(storyIds) + if (storyIds.length !== uniqueIds.size) { + console.warn('[getStories] WARNING: Duplicate story IDs found!', { + totalStories: storyIds.length, + uniqueStories: uniqueIds.size, + duplicates: storyIds.filter((id, idx) => storyIds.indexOf(id) !== idx), + allIds: storyIds + }) + } + + // Check for identical stories + const storyStrings = res.stories.map(s => JSON.stringify(s)) + const uniqueStories = new Set(storyStrings) + if (storyStrings.length !== uniqueStories.size) { + console.warn('[getStories] WARNING: Identical story objects found!', { + totalStories: storyStrings.length, + uniqueStories: uniqueStories.size, + duplicatesCount: storyStrings.length - uniqueStories.size + }) + } + } + + console.log('[getStories] Full API response:', JSON.stringify(res, null, 2)) + resolve(res) + }).catch((error) => { + console.error('[getStories] Request error:', error) + reject(error) + }) + } catch (error) { + console.error('[getStories] Error:', error) + reject(error) + } + }) + }) + } + + /** + * Track story slide view event + * @param {string|number} storyId - Story identifier (string id or numeric ids) + * @param {string|number} slideId - Slide identifier (string id or numeric ids) + * @param {string} code - Stories code + * @returns {Promise} - Promise that resolves with tracking response + */ + trackStoryView(storyId, slideId, code) { + return new Promise((resolve, reject) => { + this.push(async () => { + try { + // Get current deviceId from storage to ensure it's up-to-date + const storageData = await getData(this.shop_id) + const deviceId = storageData?.did || this.deviceId || '' + if (deviceId && deviceId !== this.deviceId) { + this.deviceId = deviceId + } + + // Validate that storyId is a positive integer (API requires number) + const numericStoryId = Number(storyId) + if (isNaN(numericStoryId) || numericStoryId <= 0 || !Number.isInteger(numericStoryId)) { + console.warn('Invalid story_id for tracking:', storyId, 'Expected positive integer') + reject(new Error(`Invalid story_id: ${storyId}. Expected positive integer.`)) + return + } + + // Validate that slideId is a positive integer + const numericSlideId = Number(slideId) + if (isNaN(numericSlideId) || numericSlideId <= 0 || !Number.isInteger(numericSlideId)) { + console.warn('Invalid slide_id for tracking:', slideId, 'Expected positive integer') + reject(new Error(`Invalid slide_id: ${slideId}. Expected positive integer.`)) + return + } + + request('track/stories', this.shop_id, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + params: { + shop_id: this.shop_id, + did: deviceId, + seance: this.userSeance, + sid: this.userSeance, + segment: this.segment, + story_id: numericStoryId, + slide_id: numericSlideId, + code: code, + event: 'view', + }, + }).then((res) => { + resolve(res) + }).catch((error) => { + reject(error) + }) + } catch (error) { + reject(error) + } + }) + }) + } + + /** + * Track story slide click event + * @param {string|number} storyId - Story identifier (string id or numeric ids) + * @param {string|number} slideId - Slide identifier (string id or numeric ids) + * @param {string} code - Stories code + * @returns {Promise} - Promise that resolves with tracking response + */ + trackStoryClick(storyId, slideId, code) { + return new Promise((resolve, reject) => { + this.push(async () => { + try { + // Get current deviceId from storage to ensure it's up-to-date + const storageData = await getData(this.shop_id) + const deviceId = storageData?.did || this.deviceId || '' + if (deviceId && deviceId !== this.deviceId) { + this.deviceId = deviceId + } + + // Validate that storyId is a positive integer (API requires number) + const numericStoryId = Number(storyId) + if (isNaN(numericStoryId) || numericStoryId <= 0 || !Number.isInteger(numericStoryId)) { + console.warn('Invalid story_id for tracking:', storyId, 'Expected positive integer') + reject(new Error(`Invalid story_id: ${storyId}. Expected positive integer.`)) + return + } + + // Validate that slideId is a positive integer + const numericSlideId = Number(slideId) + if (isNaN(numericSlideId) || numericSlideId <= 0 || !Number.isInteger(numericSlideId)) { + console.warn('Invalid slide_id for tracking:', slideId, 'Expected positive integer') + reject(new Error(`Invalid slide_id: ${slideId}. Expected positive integer.`)) + return + } + + // Debug logging + console.log('trackStoryClick called with:', { + storyId: numericStoryId, + slideId: numericSlideId, + code: code + }) + + request('track/stories', this.shop_id, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + params: { + shop_id: this.shop_id, + did: deviceId, + seance: this.userSeance, + sid: this.userSeance, + segment: this.segment, + story_id: numericStoryId, + slide_id: numericSlideId, + code: code, + event: 'click', + }, + }).then((res) => { + resolve(res) + }).catch((error) => { + reject(error) + }) + } catch (error) { + reject(error) + } + }) + }) + } + /** * @returns {Promise} */ @@ -598,14 +1025,20 @@ class MainSDK extends Performer { let pushToken + const messaging = this._ensureMessaging() + if (!messaging) { + console.warn('Firebase messaging not available') + return null + } + if (this._push_type === null && Platform.OS === 'ios') { - getAPNSToken(this.messaging).then((token) => { + getAPNSToken(messaging).then((token) => { if (DEBUG) console.log('New APN token: ', token) this.setPushTokenNotification(token) pushToken = token }) } else { - getToken(this.messaging).then((token) => { + getToken(messaging).then((token) => { if (DEBUG) console.log('New FCM token: ', token) this.setPushTokenNotification(token) pushToken = token @@ -667,7 +1100,9 @@ class MainSDK extends Performer { if (notifyBgReceive) this.pushBgReceivedListener = notifyBgReceive // Register handler - onMessage(this.messaging, async (remoteMessage) => { + const messaging = this._ensureMessaging() + if (messaging) { + onMessage(messaging, async (remoteMessage) => { if (this.lastMessageIds.includes(remoteMessage.messageId)) { return false } else { @@ -682,10 +1117,13 @@ class MainSDK extends Performer { await updPushData(remoteMessage, this.shop_id) await this.pushReceivedListener(remoteMessage) - }) + }) + } // Register background handler - setBackgroundMessageHandler(this.messaging, async (remoteMessage) => { + const messagingForBg = this._ensureMessaging() + if (messagingForBg) { + setBackgroundMessageHandler(messagingForBg, async (remoteMessage) => { if (this.lastMessageIds.includes(remoteMessage.messageId)) { return false } else { @@ -700,10 +1138,13 @@ class MainSDK extends Performer { await updPushData(remoteMessage, this.shop_id) await this.pushBgReceivedListener(remoteMessage) - }) + }) + } - // Register background handler - onNotificationOpenedApp(this.messaging, async (remoteMessage) => { + // Register notification opened handler + const messagingForOpened = this._ensureMessaging() + if (messagingForOpened) { + onNotificationOpenedApp(messagingForOpened, async (remoteMessage) => { if (this.lastMessageIds.includes(remoteMessage.messageId)) { return false } else { @@ -718,7 +1159,8 @@ class MainSDK extends Performer { await updPushData(remoteMessage, this.shop_id) await this.pushBgReceivedListener(remoteMessage) - }) + }) + } /** Subscribe to click notification */ notifee.onForegroundEvent(async ({ type, detail }) => { @@ -826,7 +1268,10 @@ class MainSDK extends Performer { */ async deleteToken() { return savePushToken(false, this.shop_id).then(async () => { - await deleteToken(this.messaging) + const messaging = this._ensureMessaging() + if (messaging) { + await deleteToken(messaging) + } }) } @@ -854,6 +1299,7 @@ class MainSDK extends Performer { { shop_id: this.shop_id, stream: this.stream, + segment: this.segment || null, }, data ), @@ -867,6 +1313,10 @@ class MainSDK extends Performer { }) } + getCurrentSegment() { + return this.segment + } + /** * @param {import('react-native-push-notification').ReceivedNotification} [notification] * @returns {Promise} diff --git a/components/Stories/ProductsCarousel.js b/components/Stories/ProductsCarousel.js new file mode 100644 index 0000000..b5b60b0 --- /dev/null +++ b/components/Stories/ProductsCarousel.js @@ -0,0 +1,231 @@ +import React, { useEffect, useRef, memo } from 'react' +import { + View, + Modal, + Text, + Pressable, + Image, + ScrollView, + Dimensions, + Animated, + Platform, + Linking, +} from 'react-native' +import { styles, formatPrice, getColorFromSettings, DEFAULT_COLORS } from './styles' + +const { width: screenWidth, height: screenHeight } = Dimensions.get('window') + +/** + * ProductsCarousel Component + * Modal carousel for displaying products horizontally + * + * @param {Object} props + * @param {boolean} props.visible - Whether carousel is visible + * @param {StoriesProduct[]} props.products - Array of products to display + * @param {string} [props.hideLabel] - Label for hide button + * @param {Function} props.onClose - Callback when carousel is closed + * @param {Function} props.onProductPress - Callback when product is pressed + * @param {Object} [props.settings] - Stories settings from API (colors, etc.) + */ +function ProductsCarousel({ + visible, + products = [], + hideLabel = 'Скрыть', + onClose, + onProductPress, + settings, +}) { + const slideAnim = useRef(new Animated.Value(screenHeight)).current + + useEffect(() => { + if (visible) { + // Animate carousel sliding up from bottom + Animated.spring(slideAnim, { + toValue: 0, + useNativeDriver: true, + tension: 65, + friction: 11, + }).start() + } else { + // Reset animation when hidden + slideAnim.setValue(screenHeight) + } + }, [visible]) + + const handleProductPress = (product) => { + // Determine which URL to use based on platform + let urlToOpen = null + + if (Platform.OS === 'ios') { + urlToOpen = product.deeplinkIos || product.url + } else if (Platform.OS === 'android') { + urlToOpen = product.deeplinkAndroid || product.url + } else { + // Fallback for other platforms + urlToOpen = product.url + } + + if (urlToOpen) { + Linking.openURL(urlToOpen).catch((err) => { + console.warn('Failed to open URL:', err) + }) + } + + // Call callback + onProductPress?.(product) + + // Close carousel + onClose?.() + } + + const handleBackdropPress = () => { + onClose?.() + } + + if (!visible || !products || products.length === 0) { + return null + } + + // Calculate carousel height based on device (like iOS) + let carouselHeight = 450 + if (screenHeight < 700) { + carouselHeight = 420 + } else if (screenHeight > 900) { + carouselHeight = 450 + } + + // Calculate product card width (like iOS: (screenWidth - 40 - 40 - 5) / 1.4) + const leftRightPadding = 40 + const spacing = 10 + const cardWidth = (screenWidth - leftRightPadding * 2 - spacing / 2) / 1.4 + + return ( + + {/* Backdrop */} + + + {/* Carousel content with bottom padding for button */} + + {products.map((product, index) => ( + handleProductPress(product)} + > + + + {product.name} + + + {/* Price row */} + + {/* Old price */} + {product.oldprice && product.oldprice > 0 && product.oldprice > product.price && ( + <> + + {product.oldprice_formatted || formatPrice(product.oldprice, product.currency)} + + {/* Discount badge */} + {product.discount_formatted && + product.discount_formatted !== '0%' && + product.discount_formatted !== null && ( + + + -{product.discount_formatted} + + + )} + + )} + + + {/* Current price */} + + {product.price_formatted || formatPrice(product.price, product.currency)} + + + ))} + + + {/* Hide button */} + + + {hideLabel} + + + + + + ) +} + +export default memo(ProductsCarousel) + diff --git a/components/Stories/StoriesList.js b/components/Stories/StoriesList.js new file mode 100644 index 0000000..1725c31 --- /dev/null +++ b/components/Stories/StoriesList.js @@ -0,0 +1,364 @@ +import React, { useState, useEffect, useCallback, useImperativeHandle, forwardRef, useMemo, useRef } from 'react' +import { + View, + FlatList, + Image, + Text, + ActivityIndicator, + StyleSheet, + Pressable, + Platform, + AppState, +} from 'react-native' +import { styles, DEFAULT_CONFIG, DEFAULT_COLORS, hexToRgba, getColorFromSettings } from './styles' +import { isStoryFullyViewed } from '../../lib/stories/storage' +import { preloadSlides, cancelAllPreloads, pausePreloading, resumePreloading } from '../../lib/stories/slidePreloader' + +/** + * StoriesList Component + * Horizontal scrollable list of story circles + * + * @param {Object} props + * @param {Object} props.sdk - SDK instance + * @param {string} props.code - Stories code identifier + * @param {Function} props.onStoryPress - Callback when story is pressed + * @param {Object} [props.style] - Additional styles for FlatList + * @param {Object} [props.contentContainerStyle] - Additional styles for FlatList content container + * @param {number} [props.iconSize] - Size of story circles + * @param {number} [props.iconMargin] - Margin between story circles + * @param {number} [props.height] - Height of the stories container + * @param {Function} [props.onLoadComplete] - Callback when stories load + */ +const StoriesList = forwardRef(function StoriesList({ + sdk, + code, + onStoryPress, + style, + contentContainerStyle, + iconSize = DEFAULT_CONFIG.iconSize, + iconMargin = DEFAULT_CONFIG.iconMargin, + height = DEFAULT_CONFIG.storyHeight, + onLoadComplete +}, ref) { + const [stories, setStories] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [viewedStates, setViewedStates] = useState({}) + const [settings, setSettings] = useState(null) + const preloadTimeoutRef = useRef(null) + const appStateSubscriptionRef = useRef(null) + + // Function to refresh viewed states + const refreshViewedStates = useCallback(async () => { + if (!stories || stories.length === 0) return + + try { + if (__DEV__) { + } + const viewedStatesMap = {} + for (const story of stories) { + const slideIds = story.slides.map(slide => slide.id) + const isViewed = await isStoryFullyViewed(story.id, slideIds) + viewedStatesMap[story.id] = isViewed + if (__DEV__) { + } + } + setViewedStates(viewedStatesMap) + if (__DEV__) { + } + } catch (err) { + console.warn('[StoriesList] Error refreshing viewed states:', err) + } + }, [stories]) + + // Expose refreshViewedStates via ref + useImperativeHandle(ref, () => ({ + refreshViewedStates, + })) + + useEffect(() => { + loadStories() + }, [sdk, code]) + + // Setup AppState listener for pause/resume preloading + useEffect(() => { + const handleAppStateChange = (nextAppState) => { + if (nextAppState === 'background' || nextAppState === 'inactive') { + pausePreloading() + } else if (nextAppState === 'active') { + resumePreloading() + } + } + + appStateSubscriptionRef.current = AppState.addEventListener('change', handleAppStateChange) + + return () => { + if (appStateSubscriptionRef.current) { + appStateSubscriptionRef.current.remove() + appStateSubscriptionRef.current = null + } + } + }, []) + + // Cleanup on unmount + useEffect(() => { + return () => { + // Cancel any pending preload timeout + if (preloadTimeoutRef.current) { + clearTimeout(preloadTimeoutRef.current) + preloadTimeoutRef.current = null + } + + // Cancel all preloads + cancelAllPreloads() + } + }, []) + + const loadStories = async () => { + if (!sdk || !code) { + setError('SDK or code not provided') + setLoading(false) + return + } + + try { + setLoading(true) + setError(null) + + const response = await sdk.getStories(code) + + if (response && response.stories) { + setStories(response.stories) + + // Save settings for styling story titles + if (response.settings) { + setSettings(response.settings) + } + + // Check viewed states for all stories + const viewedStatesMap = {} + for (const story of response.stories) { + const slideIds = story.slides.map(slide => slide.id) + const isViewed = await isStoryFullyViewed(story.id, slideIds) + viewedStatesMap[story.id] = isViewed + } + setViewedStates(viewedStatesMap) + + // Start preloading slides in background (with delay to not block UI) + // Use setTimeout with low priority to ensure it doesn't interfere with main app + if (preloadTimeoutRef.current) { + clearTimeout(preloadTimeoutRef.current) + } + preloadTimeoutRef.current = setTimeout(() => { + if (__DEV__) { + console.log('[StoriesList] Starting preload for all slides') + } + preloadSlides(response.stories, { + currentStoryIndex: 0, + currentSlideIndex: 0, + preloadAll: true, // Preload all slides in background + }) + }, 500) // 500ms delay to ensure UI is responsive + + onLoadComplete?.(true) + } else { + setError('Invalid response format') + onLoadComplete?.(false) + } + } catch (err) { + console.error('Error loading stories:', err) + setError(err.message || 'Failed to load stories') + onLoadComplete?.(false) + } finally { + setLoading(false) + } + } + + const renderStoryItem = ({ item: story, index }) => { + const isViewed = viewedStates[story.id] || story.viewed + + // Get title color from settings or use default + let titleColor = DEFAULT_COLORS.text + if (settings?.color) { + // Convert hex color to rgba format for React Native + const rgba = hexToRgba(settings.color, 1) + titleColor = `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})` + } + + // Get font size from settings or use default + const titleFontSize = settings?.fontSize || DEFAULT_CONFIG.fontSize + + // Get border colors from settings or use defaults (match iOS SDK) + const borderColorViewed = getColorFromSettings(settings, 'borderViewed', DEFAULT_COLORS.borderViewed) + const borderColorNotViewed = getColorFromSettings(settings, 'borderNotViewed', DEFAULT_COLORS.borderNotViewed) + const backgroundPinColor = getColorFromSettings(settings, 'backgroundPin', DEFAULT_COLORS.backgroundPin) + + // Component for story name with word truncation + // Rule: if a word is longer than 7 characters, truncate it to 7 characters with "..." + // Remove standard truncation (numberOfLines, ellipsizeMode) + const StoryNameText = ({ name, style }) => { + const processText = (text) => { + if (!text || typeof text !== 'string') return '' + + // Split text into words (preserve spaces) + const words = text.split(/(\s+)/) + + // Process each word + const processedWords = words.map(word => { + // If it's a space, keep it as is + if (/^\s+$/.test(word)) { + return word + } + + // If word is longer than 7 characters, truncate to 7 and add "..." + if (word.length > 7) { + return word.substring(0, 7) + '...' + } + + // Otherwise, keep the word as is + return word + }) + + return processedWords.join('') + } + + const processedName = processText(name) + + return ( + + {processedName} + + ) + } + + return ( + onStoryPress?.(story, index, settings)} + > + {/* Circle container - всегда сверху, фиксированная высота */} + + + {/* Pin indicator - match iOS SDK */} + {story.pinned && ( + + + {settings?.pinSymbol || '📌'} + + + )} + + {/* Text container - центрирован под кружком */} + + + ) + } + + const renderLoadingItem = ({ index }) => ( + + + + + ) + + // Merge contentContainerStyle with default styles + const mergedContentContainerStyle = [ + styles.storiesList, + contentContainerStyle, + ] + + if (loading) { + return ( + `loading-${index}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={mergedContentContainerStyle} + style={[{ height }, style]} + /> + ) + } + + if (error) { + return null + } + + if (stories.length === 0) { + return null + } + + return ( + story.id} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={mergedContentContainerStyle} + style={[{ height }, style]} + /> + ) +}) + +export default StoriesList diff --git a/components/Stories/StoryElements.js b/components/Stories/StoryElements.js new file mode 100644 index 0000000..93d215e --- /dev/null +++ b/components/Stories/StoryElements.js @@ -0,0 +1,607 @@ +import React from 'react' +import { + View, + Text, + Pressable, + Image, + StyleSheet, +} from 'react-native' +import { styles, formatPrice, getElementPosition, getTextAlignment, getColorFromElement, getColorFromSettings, DEFAULT_COLORS } from './styles' + +/** + * StoryElements Component + * Renders interactive elements over a story slide + * + * @param {Object} props + * @param {StoriesElement[]} props.elements - Array of story elements + * @param {Function} props.onElementPress - Callback when element is pressed + * @param {Object} [props.style] - Additional styles + * @param {Object} [props.settings] - Stories settings from API (colors, etc.) + * @param {Object} [props.slide] - Slide object with settings (for button titles, etc.) + */ +export default function StoryElements({ elements = [], onElementPress, style, settings, slide }) { + if (!elements || elements.length === 0) { + return null + } + + + // Check if there's a main button (type === 'button') + const hasMainButton = elements.some(el => el.type === 'button') + + // Separate buttons from other elements + const buttons = [] + const otherElements = [] + const productsToShow = [] // Products to show on slide (for products type only) + const promocodeBanners = [] // Separate promocode banners + let productsElement = null + + elements.forEach((element, index) => { + if (element.type === 'button') { + buttons.push({ element, index }) + } else if (element.type === 'products') { + // Store products element for debug + if (!productsElement) { + productsElement = element + } + + // Products element: show product on slide only if slide type is 'products' AND exactly one product + // For other slide types (image, video), products are shown only in carousel + const productsArray = element.products || element.items || [] + const isProductsSlide = slide?.type === 'products' + if (isProductsSlide && productsArray.length === 1) { + // Add products to show on slide (only for products slide type) + productsToShow.push({ element, index }) + // Always show bottom price banner for single-product slides + promocodeBanners.push({ element, index, product: productsArray[0] }) + } + // Always add button for opening carousel (if there are products) + if (productsArray.length > 0) { + buttons.push({ element, index }) + } + } else if (element.type === 'product') { + // Product element (promocode) - show product card on slide + button for carousel + const productData = element.item || element.product + if (productData) { + const product = productData + + // Add product card to otherElements to show on slide + otherElements.push({ element, index }) + + // Always show bottom price banner for product slides (price should not be inside the card) + promocodeBanners.push({ element, index, product }) + + // Add button for opening carousel (if needed) + // Note: For product type, button might not be needed if product is shown on slide + } else { + otherElements.push({ element, index }) + } + } else { + otherElements.push({ element, index }) + } + }) + + const renderButton = ({ element, index }) => { + if (element.type === 'button') { + // Main button - always at bottom + // Match iOS SDK: element.background -> white fallback, element.color -> black fallback + const buttonBackground = getColorFromElement(element, 'background', '#FFFFFF') + const buttonTextColor = getColorFromElement(element, 'color', '#000000') + + return ( + onElementPress?.(element)} + > + + {element.title || 'Button'} + + + ) + } else if (element.type === 'products') { + // Products button - above main button if exists, otherwise lower + const buttonStyle = hasMainButton + ? styles.elementProductsButtonFixed + : styles.elementProductsButtonFixedSingle + + // Get button title from slide settings (priority) or element labels (fallback) + // Support both snake_case (from server) and camelCase formats + // Priority: slide.settings?.labels?.showCarousel/show_carousel -> slide.settings?.buttonTitle/button_title -> element.labels?.showCarousel/show_carousel -> 'Посмотреть' + const buttonText = slide?.settings?.labels?.showCarousel + || slide?.settings?.labels?.show_carousel + || slide?.settings?.buttonTitle + || slide?.settings?.button_title + || element.labels?.showCarousel + || element.labels?.show_carousel + || 'Посмотреть' + + // Match iOS SDK: element.background -> white fallback, element.color -> black fallback + const buttonBackground = getColorFromElement(element, 'background', '#FFFFFF') + const buttonTextColor = getColorFromElement(element, 'color', '#000000') + + // Use productsElement if available, otherwise try to find original element from elements array + const elementWithProducts = productsElement || elements.find(el => el.type === 'products' && el.products) || element + + return ( + { + console.log('[StoryElements] Products button pressed:', { + elementType: element.type, + elementHasProducts: !!element.products, + elementProductsLength: element.products?.length, + productsElementHasProducts: !!productsElement?.products, + productsElementProductsLength: productsElement?.products?.length, + elementWithProductsHasProducts: !!elementWithProducts.products, + elementWithProductsProductsLength: elementWithProducts.products?.length, + }) + // Use elementWithProducts to ensure we have products + console.log('[StoryElements] Passing element to onElementPress:', { + type: elementWithProducts.type, + hasProducts: !!elementWithProducts.products, + productsLength: elementWithProducts.products?.length, + }) + onElementPress?.(elementWithProducts) + }} + > + + {buttonText} + + + ) + } + return null + } + + const renderElement = ({ element, index }) => { + // Support both camelCase and snake_case for yOffset + const yOffset = element.yOffset !== undefined ? element.yOffset : element.y_offset + // Convert to number if it's a string + const yOffsetNum = typeof yOffset === 'string' ? parseFloat(yOffset) : yOffset + const elementStyle = { + ...getElementPosition(yOffsetNum), + } + + switch (element.type) { + + case 'text_block': + // Support both camelCase and snake_case from API + const textInput = element.textInput || element.text_input || '' + + + // Skip rendering if no text content + if (!textInput || textInput.trim() === '') { + if (__DEV__) { + console.warn('[StoryElements] text_block element has no textInput:', element) + } + return null + } + + // Match iOS SDK: element.textColor -> black fallback + const textColor = getColorFromElement(element, 'textColor', '#000000') + // Background with opacity (iOS SDK uses textBackgroundColor with opacity -> clear fallback) + const textBgColorRaw = element.textBackgroundColor || element.text_background_color + const textBgOpacityRaw = element.textBackgroundColorOpacity || element.text_background_color_opacity || '80' + + // Check if background color exists and is not empty string + const hasTextBackground = textBgColorRaw && typeof textBgColorRaw === 'string' && textBgColorRaw.trim() !== '' && textBgColorRaw !== 'transparent' && textBgColorRaw !== 'clear' + + let textBgColor = undefined + if (hasTextBackground) { + // Convert opacity to hex if needed + let opacityHex = textBgOpacityRaw + if (typeof textBgOpacityRaw === 'number') { + // Convert 0-1 range to 0-255 hex + opacityHex = Math.round(textBgOpacityRaw * 255).toString(16).padStart(2, '0') + } else if (typeof textBgOpacityRaw === 'string') { + // If it's a percentage like "80%", convert to hex + if (textBgOpacityRaw.endsWith('%')) { + const percent = parseFloat(textBgOpacityRaw) + opacityHex = Math.round((percent / 100) * 255).toString(16).padStart(2, '0') + } + // If it's already hex (like "80"), use as is + } + + // Combine hex color with opacity (React Native format: #RRGGBBAA) + textBgColor = `${textBgColorRaw}${opacityHex}` + } + + + // Support both camelCase and snake_case for all fields + // Convert fontSize to number (API may return string) + const fontSizeRaw = element.fontSize !== undefined ? element.fontSize : element.font_size + const fontSize = typeof fontSizeRaw === 'string' ? parseFloat(fontSizeRaw) || undefined : fontSizeRaw + const fontSizeNum = (typeof fontSize === 'number' && fontSize > 0) ? fontSize : styles.elementText.fontSize + + const textBold = element.textBold !== undefined ? element.textBold : (element.text_bold !== undefined ? element.text_bold : element.bold) + const textItalic = element.textItalic !== undefined ? element.textItalic : (element.text_italic !== undefined ? element.text_italic : element.italic) + const textAlignment = element.textAlignment || element.text_align + + // Convert textLineSpacing to number (API may return string) + const textLineSpacingRaw = element.textLineSpacing !== undefined ? element.textLineSpacing : element.text_line_spacing + const textLineSpacing = typeof textLineSpacingRaw === 'string' ? parseFloat(textLineSpacingRaw) || undefined : textLineSpacingRaw + + // Add minimum top offset to avoid progress bar overlap + // Status bar (~24-30px) + Progress bar area (top: 65, height ~3px) + Close button (~40px) + Safe margin + // Match iOS SDK approach: add safe area offset for text blocks + const MIN_TOP_OFFSET = 150 // Safe area to avoid status bar, progress bar and close button + + // Adjust top position: if yOffset is small (near top), add minimum offset + // If yOffset is percentage and small, convert to pixels with minimum + let adjustedTop = elementStyle.top + if (typeof yOffsetNum === 'number' && yOffsetNum < 20) { + // Small yOffset (near top) - use minimum offset in pixels + adjustedTop = MIN_TOP_OFFSET + } else if (typeof adjustedTop === 'string' && adjustedTop.endsWith('%')) { + // Percentage-based: check if it's too close to top + const percentage = parseFloat(adjustedTop) + if (percentage < 20) { + adjustedTop = MIN_TOP_OFFSET + } + } else if (typeof adjustedTop === 'number' && adjustedTop < MIN_TOP_OFFSET) { + adjustedTop = MIN_TOP_OFFSET + } + + // Match iOS SDK: wrap Text in View with background container + // backgroundColor goes on View, not Text + // Padding: 8px top/bottom, 16px left/right if background exists + // Margin: 16px left/right from screen edges (like iOS SDK stackView) + const hasBackground = !!textBgColor + + return ( + + + {textInput} + + + ) + + case 'product': + // In iOS SDK, product data comes from "item" field, not "product" + const productData = element.item || element.product + if (productData) { + // Product slide: title above card; card contains only photo; price is rendered in bottom banner + const product = productData + + // Use yOffset if provided, otherwise center the card (like iOS) + const productCardStyle = element.yOffset !== undefined && element.yOffset !== null + ? elementStyle + : { + position: 'absolute', + top: 170, // Below title + left: 16, + right: 16, + width: 'auto', + } + + return ( + + {/* Product title above card (like screenshot) */} + onElementPress?.(element)} + > + + {product.name} + + + + {/* Product card (photo only) */} + onElementPress?.(element)} + > + + + + ) + } + return null + + default: + return null + } + } + + const renderProductsOnSlide = ({ element, index }) => { + if (!element.products || element.products.length === 0) { + return null + } + + // Show product card on slide only if there's exactly one product (for products type) + if (element.products.length !== 1) { + return null + } + + // Show first product as main card on slide + const firstProduct = element.products[0] + if (!firstProduct) { + return null + } + + // Use yOffset if provided, otherwise center the card + const elementStyle = element.yOffset !== undefined && element.yOffset !== null + ? getElementPosition(element.yOffset) + : { + position: 'absolute', + top: 170, // Below title + left: 16, + right: 16, + width: 'auto', + } + + return ( + + {/* Product title above card (like iOS) */} + onElementPress?.(element)} + > + + {firstProduct.name} + + + + {/* Product card (photo only; price is in bottom banner) */} + onElementPress?.(element)} + > + + + + ) + } + + + const renderPromocodeBanner = ({ element, index, product }) => { + // Get colors from settings (using product_* keys from API) + const priceSectionBackground = getColorFromSettings( + settings, + 'productPriceBackground', + DEFAULT_COLORS.bannerPriceSectionBackground + ) + const priceSectionFontColor = getColorFromSettings( + settings, + 'productPriceColor', + DEFAULT_COLORS.bannerPriceSectionFont + ) + const oldPriceSectionFontColor = getColorFromSettings( + settings, + 'productOldPriceColor', + DEFAULT_COLORS.bannerOldPriceSectionFont + ) + const promocodeSectionBackground = getColorFromSettings( + settings, + 'productPromocodeBackground', + DEFAULT_COLORS.bannerPromocodeSectionBackground + ) + const promocodeSectionFontColor = getColorFromSettings( + settings, + 'bannerPromocodeSectionFontColor', + DEFAULT_COLORS.bannerPromocodeSectionFont + ) + const discountSectionBackground = getColorFromSettings( + settings, + 'productDiscountBackground', + DEFAULT_COLORS.bannerDiscountSectionBackground + ) + // Colors for plain banner (when no discount/promocode) + const plainBannerBackground = getColorFromSettings( + settings, + 'productPriceBackground', + '#FFFFFF' + ) + const plainBannerTextColor = getColorFromSettings( + settings, + 'productPriceColor', + '#000000' + ) + const hasPromocode = product.promocode && product.promocode !== '' + // products items may provide discount as discount_percent (number) or discount_formatted (string like "10%") + const discountPercentFromNumber = + typeof product.discount_percent === 'number' ? product.discount_percent : Number(product.discount_percent) + const discountPercentFromFormatted = + typeof product.discount_formatted === 'string' + ? Number(String(product.discount_formatted).replace('%', '').trim()) + : Number(product.discount_formatted) + const normalizedDiscountPercent = + Number.isFinite(discountPercentFromNumber) && discountPercentFromNumber > 0 + ? discountPercentFromNumber + : Number.isFinite(discountPercentFromFormatted) && discountPercentFromFormatted > 0 + ? discountPercentFromFormatted + : 0 + const hasDiscount = normalizedDiscountPercent > 0 + const hasOldPrice = product.oldprice && product.oldprice > 0 && product.oldprice > product.price + + // Requirement: price banner should be colored only when there is a discount. + // If there is no discount and no promocode, show a plain white banner with black text. + const useColoredBanner = hasDiscount || hasOldPrice + const usePlainBanner = !useColoredBanner && !hasPromocode + + const priceText = + product.price_with_promocode_formatted || + product.price_formatted || + formatPrice(product.price, product.currency) + + if (usePlainBanner) { + return ( + + + {priceText} + + + ) + } + + return ( + + {/* Price section (left) - orange background */} + + {useColoredBanner && hasOldPrice && ( + + {product.oldprice_formatted || formatPrice(product.oldprice, product.currency)} + + )} + + {priceText} + + + + {/* Promocode/Discount section (right) */} + { + // Copy promocode to clipboard if available + if (hasPromocode && product.promocode) { + // Note: Clipboard API would need to be imported + // Clipboard.setString(product.promocode) + } + onElementPress?.(element) + }} + > + {hasPromocode ? ( + <> + {element.title && ( + + {element.title} + + )} + + {product.promocode} + + + ) : hasDiscount ? ( + + -{normalizedDiscountPercent}% + + ) : null} + + + ) + } + + + return ( + + {/* Render other elements (text_block) with yOffset positioning */} + {otherElements.map(({ element, index }) => renderElement({ element, index })).filter(Boolean)} + {/* Render products on slide (for products type only, if exactly one product) */} + {productsToShow.map(({ element, index }) => renderProductsOnSlide({ element, index }))} + {/* Render promocode banners at bottom (fixed positioning) */} + {promocodeBanners.map(({ element, index, product }) => renderPromocodeBanner({ element, index, product }))} + {/* Render buttons at bottom (fixed positioning) */} + {buttons.map(({ element, index }) => renderButton({ element, index }))} + + ) +} diff --git a/components/Stories/StorySlide.js b/components/Stories/StorySlide.js new file mode 100644 index 0000000..e4e8145 --- /dev/null +++ b/components/Stories/StorySlide.js @@ -0,0 +1,734 @@ +import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react' +import { + View, + Image, + ActivityIndicator, + Pressable, + Text, + Platform, + StyleSheet, + Dimensions, +} from 'react-native' +import MaterialIcons from 'react-native-vector-icons/MaterialIcons' +import { VolumeManager, getRingerMode, RINGER_MODE } from 'react-native-volume-manager' +import StoryElements from './StoryElements' +import { styles, getDuration, preloadMedia, hexToRgb, DEFAULT_COLORS } from './styles' +import { getCachedImage, isImageCached } from '../../lib/stories/imageCache' +import { getCachedVideoPath, isVideoCached, getVideoDuration } from '../../lib/stories/videoCache' + +// Note: react-native-video will be added as dependency +// For now, we'll use a placeholder that can be replaced +let Video = null +try { + Video = require('react-native-video').default +} catch (error) { + console.warn('react-native-video not installed. Video slides will not work.') +} + +/** + * StorySlide Component + * Renders individual slide content with image/video and interactive elements + * + * @param {Object} props + * @param {Slide} props.slide - Slide data object + * @param {boolean} props.isActive - Whether slide is currently active + * @param {Function} props.onElementPress - Callback when element is pressed + * @param {Object} [props.settings] - Stories settings from API (colors, etc.) + * @param {Function} props.onLoad - Callback when slide loads + * @param {Object} [props.style] - Additional styles + */ +/** + * Determine initial mute state based on system silent mode + * Uses react-native-volume-manager to check device mute/silent state + * For iOS: Checks mute switch state using addSilentListener + * For Android: Checks ringer mode (SILENT, VIBRATE, or NORMAL) + */ +const getInitialMuteState = async () => { + try { + if (Platform.OS === 'ios') { + // For iOS: Check mute switch state + // Use addSilentListener to get initial state + return new Promise((resolve) => { + let resolved = false + const listener = VolumeManager.addSilentListener((status) => { + // status.isMuted indicates if device is in silent mode + // status.initialQuery indicates if this is the initial query + if (status.initialQuery && !resolved) { + resolved = true + listener.remove() + // isMuted === true means device is in silent mode (muted) + // isMuted === false means device is not in silent mode (unmuted) + // isMuted === undefined means unknown, default to muted + resolve(status.isMuted === true) + } + }) + // Timeout fallback: if no response in 1 second, default to muted + setTimeout(() => { + if (!resolved) { + resolved = true + listener.remove() + resolve(true) // Default to muted + } + }, 1000) + }) + } else if (Platform.OS === 'android') { + // For Android: Check ringer mode + // RINGER_MODE.silent or RINGER_MODE.vibrate means device is in silent mode + const ringerMode = await getRingerMode() + // Check if ringer mode is silent or vibrate + // RINGER_MODE values: silent = 0, vibrate = 1, normal = 2 + if (ringerMode === RINGER_MODE.silent || ringerMode === RINGER_MODE.vibrate) { + return true // Device is in silent/vibrate mode = muted + } + // RINGER_MODE.normal or undefined = not muted + return false + } + } catch (error) { + // If check fails, default to muted (better UX) + if (__DEV__) { + console.warn('[StorySlide] Failed to check system mute state:', error) + } + return true // Default to muted + } + // Fallback: default to muted + return true +} + +function StorySlide({ slide, isActive, onElementPress, onLoad, onMediaLoaded, onVideoDuration, onVideoEnd, onVideoProgress, settings, style }) { + const [imageLoaded, setImageLoaded] = useState(false) + const [videoLoaded, setVideoLoaded] = useState(false) + const [mediaLoading, setMediaLoading] = useState(true) + const [muted, setMuted] = useState(true) // Initial state (will be updated from system detection) + const [videoSource, setVideoSource] = useState(null) // Cached video path or original URL + const [videoIsCached, setVideoIsCached] = useState(false) // Track if video is from cache + const [videoOpacityReady, setVideoOpacityReady] = useState(false) // Track when video opacity can be shown (with delay) + // Use ref instead of state for aspectRatio to prevent re-renders that cause jumps + const videoAspectRatioRef = useRef(null) + const slideIdRef = useRef(null) + const mediaLoadedCallbackRef = useRef(onMediaLoaded) + const muteStateInitializedRef = useRef(false) + const videoOpacityTimeoutRef = useRef(null) // Timeout for opacity delay + + // Update callback ref when it changes + useEffect(() => { + mediaLoadedCallbackRef.current = onMediaLoaded + }, [onMediaLoaded]) + + // Initialize mute state from system detection + useEffect(() => { + if (!muteStateInitializedRef.current) { + muteStateInitializedRef.current = true + getInitialMuteState().then((isMuted) => { + setMuted(isMuted) + }).catch((error) => { + if (__DEV__) { + console.warn('[StorySlide] Failed to initialize mute state:', error) + } + // Keep default muted state on error + }) + } + }, []) + + useEffect(() => { + // Check if slide actually changed + const slideChanged = slideIdRef.current !== slide?.id + + if (slide && isActive) { + // Reset loading state when slide changes + if (slideChanged) { + slideIdRef.current = slide.id + setImageLoaded(false) + setVideoLoaded(false) + setVideoOpacityReady(false) // Reset opacity ready state + setMediaLoading(true) + setVideoIsCached(false) // Reset cache status + videoAspectRatioRef.current = null // Reset aspectRatio ref for new slide + + // Clear opacity timeout if exists + if (videoOpacityTimeoutRef.current) { + clearTimeout(videoOpacityTimeoutRef.current) + videoOpacityTimeoutRef.current = null + } + + // For video slides, check cache first, then fallback to original URL + if (slide.type === 'video' && slide.background) { + if (__DEV__) { + console.log(`[StorySlide] Checking cache for video slide ${slide.id} (type: ${typeof slide.id})`) + } + // Check cache first - if cached, use it immediately for instant playback + // Use async/await pattern to ensure we wait for the result + ;(async () => { + try { + const cached = await isVideoCached(slide.id) + if (__DEV__) { + console.log(`[StorySlide] Cache check result for slide ${slide.id}: ${cached}`) + } + if (cached) { + // Video is cached - get cached path and use it immediately + const cachedPath = await getCachedVideoPath(slide.id, slide.background) + if (cachedPath) { + if (__DEV__) { + console.log(`[StorySlide] Using cached video for slide ${slide.id}: ${cachedPath}`) + } + setVideoIsCached(true) + setVideoSource({ uri: cachedPath }) + // For cached videos, mark as loaded immediately to start playback faster + // Video component will still fire onReadyForDisplay, but we don't wait for it + setVideoLoaded(true) + setMediaLoading(false) + setTimeout(() => { + mediaLoadedCallbackRef.current?.() + onLoad?.() + }, 0) + } else { + if (__DEV__) { + console.warn(`[StorySlide] Cached path not found for slide ${slide.id}, using original URL`) + } + // Fallback to original URL if cached path not found + setVideoSource({ uri: slide.background }) + } + } else { + if (__DEV__) { + console.log(`[StorySlide] Video not cached for slide ${slide.id}, using streaming`) + } + // Not cached - use original URL for streaming + setVideoSource({ uri: slide.background }) + } + } catch (error) { + if (__DEV__) { + console.warn('[StorySlide] Error checking video cache:', error) + } + // Fallback to original URL on error + setVideoSource({ uri: slide.background }) + } + })() + } else { + // For non-video slides, reset video source + setVideoSource(null) + } + + // Reset mute state to initial value when slide changes + // Check system silent mode for each new slide + // For video slides, ensure muted state matches device silent mode + if (slide.type === 'video') { + getInitialMuteState().then((isMuted) => { + if (__DEV__) { + console.log(`[StorySlide] Setting muted=${isMuted} for video slide ${slide.id} based on device silent mode`) + } + setMuted(isMuted) + }).catch((error) => { + if (__DEV__) { + console.warn('[StorySlide] Failed to check mute state on slide change:', error) + } + // Keep current state or default to muted on error + setMuted(true) + }) + } else { + // For non-video slides, reset to default muted state + setMuted(true) + } + // Clear any pending video load timeout + if (videoLoadTimeoutRef.current) { + clearTimeout(videoLoadTimeoutRef.current) + videoLoadTimeoutRef.current = null + } + } else if (slide && isActive && slide.type === 'video') { + // When slide becomes active (but didn't change), check mute state for video slides + // This ensures video opens with correct mute state based on device silent mode + getInitialMuteState().then((isMuted) => { + if (__DEV__) { + console.log(`[StorySlide] Updating muted=${isMuted} for active video slide ${slide.id} based on device silent mode`) + } + setMuted(isMuted) + }).catch((error) => { + if (__DEV__) { + console.warn('[StorySlide] Failed to check mute state when slide became active:', error) + } + // Keep current state or default to muted on error + setMuted(true) + }) + } + + // If slide has no background media, mark as loaded immediately + if (!slide.background) { + setMediaLoading(false) + setImageLoaded(true) + setVideoLoaded(true) + setTimeout(() => { + mediaLoadedCallbackRef.current?.() + onLoad?.() + }, 0) + return + } + + // For images, use Image.getSize to check if image is available + if (slide.type !== 'video' && slide.background) { + // Try to get image size - this will succeed if image is cached or can be loaded + Image.getSize( + slide.background, + (width, height) => { + // If we can get size, image is ready - set loaded immediately + setImageLoaded(true) + setMediaLoading(false) + // Always call callback when image is ready, even if already loaded + setTimeout(() => { + mediaLoadedCallbackRef.current?.() + onLoad?.() + }, 0) + }, + (error) => { + // Continue with normal loading flow - onLoad should fire + } + ) + + // Also try prefetch as backup + Image.prefetch(slide.background) + .then(() => { + // Prefetch success doesn't guarantee onLoad will fire, so don't set loaded here + }) + .catch((error) => { + // Silent fail - will use onLoad event + }) + } + + // Preload preview + if (slide.preview) { + preloadMedia(slide.preview) + } + + // Timeout: if image doesn't load in 2 seconds, show it anyway + const timeoutId = setTimeout(() => { + setImageLoaded((prevLoaded) => { + if (!prevLoaded && slide.type !== 'video') { + // Call callback when timeout forces image to show + setTimeout(() => { + mediaLoadedCallbackRef.current?.() + }, 0) + return true + } + return prevLoaded + }) + }, 2000) + + return () => { + clearTimeout(timeoutId) + // Also clear video load timeout on cleanup + if (videoLoadTimeoutRef.current) { + clearTimeout(videoLoadTimeoutRef.current) + videoLoadTimeoutRef.current = null + } + // Also clear opacity timeout on cleanup + if (videoOpacityTimeoutRef.current) { + clearTimeout(videoOpacityTimeoutRef.current) + videoOpacityTimeoutRef.current = null + } + } + } else if (!slide) { + // Reset when slide is null + slideIdRef.current = null + setImageLoaded(false) + setVideoLoaded(false) + setVideoOpacityReady(false) // Reset opacity ready state + setMediaLoading(true) + videoAspectRatioRef.current = null // Reset aspectRatio ref + + // Clear opacity timeout if exists + if (videoOpacityTimeoutRef.current) { + clearTimeout(videoOpacityTimeoutRef.current) + videoOpacityTimeoutRef.current = null + } + // Clear video load timeout + if (videoLoadTimeoutRef.current) { + clearTimeout(videoLoadTimeoutRef.current) + videoLoadTimeoutRef.current = null + } + } + + // Cleanup function - clear timeouts on unmount + return () => { + if (videoOpacityTimeoutRef.current) { + clearTimeout(videoOpacityTimeoutRef.current) + videoOpacityTimeoutRef.current = null + } + if (videoLoadTimeoutRef.current) { + clearTimeout(videoLoadTimeoutRef.current) + videoLoadTimeoutRef.current = null + } + } + }, [slide?.id, isActive]) // Use slide?.id to track slide changes more reliably + + useEffect(() => { + // Check if media is loaded + const isMediaLoaded = slide?.type === 'video' ? videoLoaded : imageLoaded + + if (isMediaLoaded && mediaLoading) { + // Immediately hide loader when media is loaded + setMediaLoading(false) + // Call callbacks + onMediaLoaded?.() + onLoad?.() + } else if (!isMediaLoaded && !mediaLoading && slide) { + // If media is not loaded but loader is hidden, show loader again + setMediaLoading(true) + } + }, [imageLoaded, videoLoaded, slide?.type, slide?.id]) + + const handleImageLoad = useCallback((event) => { + setImageLoaded(true) + setMediaLoading(false) + // Always call callback when image loads + setTimeout(() => { + mediaLoadedCallbackRef.current?.() + onLoad?.() + }, 0) + }, [onLoad]) + + const handleImageLoadEnd = useCallback(() => { + // Fallback: if onLoad didn't fire, consider loaded when onLoadEnd fires + setImageLoaded((prevLoaded) => { + if (!prevLoaded) { + setMediaLoading(false) + // Call callback when load ends + setTimeout(() => { + mediaLoadedCallbackRef.current?.() + }, 0) + return true + } + return prevLoaded + }) + }, []) + + const handleImageError = useCallback((error) => { + console.warn('[StorySlide] Image load error:', error, slide?.background) + setImageLoaded(true) // Still consider loaded to show fallback + }, [slide?.background]) + + const videoLoadTimeoutRef = useRef(null) + + const handleVideoLoad = useCallback((data) => { + // onLoad fires when metadata is loaded, including duration and naturalSize + // According to react-native-video documentation: data.duration is always in SECONDS + if (__DEV__) { + console.log(`[StorySlide] Video onLoad fired for slide ${slide?.id}`, data) + } + if (data && slide?.id) { + const duration = data.duration + + // Get naturalSize to calculate aspect ratio + // Store in ref instead of state to prevent re-renders that cause jumps + // We'll use it to determine resizeMode, but won't trigger React re-render + if (data.naturalSize && data.naturalSize.width && data.naturalSize.height) { + const aspectRatio = data.naturalSize.width / data.naturalSize.height + const previousAspectRatio = videoAspectRatioRef.current + videoAspectRatioRef.current = aspectRatio // Store in ref, not state + + if (__DEV__) { + console.log(`[StorySlide] Video onLoad - aspectRatio: ${aspectRatio.toFixed(2)} (${data.naturalSize.width}x${data.naturalSize.height}) for slide ${slide.id}`) + console.log(`[StorySlide] Stored aspectRatio in ref (not state) to prevent re-renders`) + console.log(`[StorySlide] Previous aspectRatio: ${previousAspectRatio}, New aspectRatio: ${aspectRatio.toFixed(2)}`) + console.log(`[StorySlide] resizeMode should be: ${aspectRatio < 1 ? 'cover' : 'contain'}, but using fixed "contain" to prevent jumps`) + } + + // Note: We're not updating resizeMode dynamically to prevent jumps + // Video will use resizeMode="contain" which works for all videos + } + + // Check if duration is valid (number, positive, not NaN, finite) + if (typeof duration === 'number' && !isNaN(duration) && isFinite(duration) && duration > 0) { + // Duration is in seconds, pass it as-is to parent component + if (__DEV__) { + console.log(`[StorySlide] Video duration: ${duration.toFixed(3)}s (${(duration * 1000).toFixed(0)}ms) for slide ${slide.id}`) + } + onVideoDuration?.(duration, slide.id) + } else if (__DEV__) { + console.warn(`[StorySlide] Invalid video duration: ${duration} for slide ${slide.id}`) + } + } + // Don't set videoLoaded here - wait for onReadyForDisplay + // This ensures video is truly ready before starting the progress timer + }, [onVideoDuration, slide?.id]) + + const handleVideoReadyForDisplay = useCallback(() => { + // This fires when video is actually ready to play + // For cached videos, we already set videoLoaded=true, but this confirms it's ready + // Clear the fallback timeout since we're ready + if (__DEV__) { + console.log(`[StorySlide] Video ready for display: slide ${slide?.id}`) + // Read current aspectRatio from state at call time, don't include in dependencies + // Including videoAspectRatio in dependencies causes callback to be recreated when it changes + // This might cause issues if callback is called with stale closure + console.log(`[StorySlide] Current state - videoLoaded: ${videoLoaded}, videoIsCached: ${videoIsCached}`) + } + if (videoLoadTimeoutRef.current) { + clearTimeout(videoLoadTimeoutRef.current) + videoLoadTimeoutRef.current = null + } + setVideoLoaded(true) + setMediaLoading(false) + + // Clear any existing opacity timeout + if (videoOpacityTimeoutRef.current) { + clearTimeout(videoOpacityTimeoutRef.current) + } + + // Add 50ms delay before showing video to allow it to stabilize + // This prevents visual jumps that occur when video changes size after initial render + videoOpacityTimeoutRef.current = setTimeout(() => { + if (__DEV__) { + console.log(`[StorySlide] Setting videoOpacityReady=true after 50ms delay for slide ${slide?.id}`) + } + setVideoOpacityReady(true) + }, 50) + + // Notify parent that media is loaded + if (mediaLoadedCallbackRef.current) { + mediaLoadedCallbackRef.current() + } + }, [slide?.id, videoLoaded, videoIsCached]) + + const handleVideoError = useCallback((error) => { + console.warn('[StorySlide] Video load error:', error, slide?.background, slide?.id) + setVideoLoaded(true) // Still consider loaded to show fallback + }, [slide?.background, slide?.id]) + + const handleVideoEnd = useCallback(() => { + // Video playback ended - notify parent to move to next slide + if (__DEV__) { + console.log(`[StorySlide] Video ended for slide ${slide?.id}`) + } + onVideoEnd?.() + }, [onVideoEnd, slide?.id]) + + const handleVideoProgress = useCallback((data) => { + // Report video playback progress to parent for synchronization + // data.currentTime is in seconds + if (data?.currentTime !== undefined && slide?.id) { + onVideoProgress?.(data.currentTime, slide.id) + } + }, [onVideoProgress, slide?.id]) + + const handleToggleMute = useCallback(() => { + setMuted(prev => !prev) + }, []) + + // Calculate resizeMode based on aspectRatio from ref (not state) + // This allows us to use correct resizeMode without causing re-renders + // For vertical videos (aspectRatio < 1), use "cover" to fill screen height + // For horizontal videos (aspectRatio >= 1), use "contain" to show full video + // Use "contain" as default until aspectRatio is known + const videoResizeMode = useMemo(() => { + const aspectRatio = videoAspectRatioRef.current + if (aspectRatio === null) { + return "contain" // Safe default + } + return aspectRatio < 1 ? "cover" : "contain" + }, []) // Empty deps - we'll read from ref, not state + + // Log resizeMode calculation + if (__DEV__) { + const aspectRatio = videoAspectRatioRef.current + console.log(`[StorySlide] videoResizeMode: ${videoResizeMode} (aspectRatio from ref: ${aspectRatio}, slide: ${slide?.id})`) + } + + // Memoize background rendering to avoid recreation on every render + // IMPORTANT: Do NOT include videoAspectRatio or videoResizeMode in dependencies + // This prevents backgroundElement from recalculating when aspectRatio changes + // We use fixed resizeMode="contain" to prevent any changes + const backgroundElement = useMemo(() => { + if (__DEV__) { + console.log(`[StorySlide] backgroundElement useMemo recalculating - slide: ${slide?.id}`) + } + if (!slide?.background) { + return null + } + + if (slide.type === 'video' && Video) { + // Use videoSource if set, otherwise fallback to original URL + // videoSource is initialized with original URL immediately, so this should always have a value + const baseSource = videoSource || { uri: slide.background } + const isVideoReady = videoLoaded || videoIsCached + + // Configure streaming - start playback after loading small buffer + // For cached videos, use minimal buffering for instant playback + // For streaming videos, use normal buffering + // Note: source object is recreated, but Video has stable key, so it won't re-initialize + const source = { + ...baseSource, + bufferConfig: videoIsCached ? { + // Cached video - minimal buffering for instant playback + minBufferMs: 1000, // Keep at least 1 second buffered + maxBufferMs: 5000, // Maximum buffer size (5 seconds) + bufferForPlaybackMs: 500, // Need 0.5 seconds to start playback + bufferForPlaybackAfterRebufferMs: 1000, // Need 1 second after rebuffering + } : { + // Streaming video - optimized buffering for faster start + // react-native-video has built-in cache, so subsequent plays should be faster + minBufferMs: 3000, // Keep at least 3 seconds buffered (reduced from 5) + maxBufferMs: 15000, // Maximum buffer size (15 seconds, reduced from 20) + bufferForPlaybackMs: 1000, // Need 1 second to start playback (reduced from 2) + bufferForPlaybackAfterRebufferMs: 3000, // Need 3 seconds after rebuffering (reduced from 5) + } + } + + // Use videoResizeMode which is calculated from ref (doesn't cause re-renders) + // But for now, use fixed "contain" to prevent any jumps + const fixedResizeMode = "contain" + + if (__DEV__) { + const aspectRatioFromRef = videoAspectRatioRef.current + console.log(`[StorySlide] Rendering video in backgroundElement - slide: ${slide?.id}, resizeMode: ${fixedResizeMode}, aspectRatio(ref): ${aspectRatioFromRef}, isVideoReady: ${isVideoReady}, videoOpacityReady: ${videoOpacityReady}`) + } + + // Use videoOpacityReady instead of isVideoReady for opacity + // This adds 50ms delay after onReadyForDisplay to allow video to stabilize + const containerOpacity = videoOpacityReady ? 1 : 0 + + return ( + + + ) + } else { + // Image slide or video fallback + return ( + + ) + } + // IMPORTANT: Do NOT include videoAspectRatio or videoResizeMode in dependencies + // This prevents backgroundElement from recalculating when aspectRatio changes + // Note: videoLoaded and videoIsCached are included but Video has stable key, so it won't re-create + // The key ensures Video component persists across state changes, preventing jumps + // source is memoized inside, so it won't cause re-renders + // videoOpacityReady is used for opacity but not in dependencies to prevent unnecessary recalculations + }, [slide?.background, slide?.id, slide?.type, videoLoaded, imageLoaded, isActive, muted, videoSource, videoIsCached, videoOpacityReady, handleVideoLoad, handleVideoReadyForDisplay, handleVideoError, handleVideoEnd, handleVideoProgress, handleImageLoad, handleImageLoadEnd, handleImageError]) + + // Memoize background color to avoid recalculation on every render + // Match iOS SDK: slide.backgroundColor or slide.background_color -> black fallback + const backgroundColor = useMemo(() => { + // Support both camelCase and snake_case (API might return background_color) + const bgColor = slide?.backgroundColor || slide?.background_color + + if (bgColor && typeof bgColor === 'string' && bgColor.trim() !== '') { + try { + const rgb = hexToRgb(bgColor) + // hexToRgb returns values 0-1, need to multiply by 255 for rgba + return `rgba(${Math.round(rgb.red * 255)}, ${Math.round(rgb.green * 255)}, ${Math.round(rgb.blue * 255)}, 1)` + } catch (error) { + console.warn('[StorySlide] Error parsing background color:', error, bgColor) + return '#000000' + } + } + // iOS SDK fallback: black + return '#000000' + }, [slide?.backgroundColor, slide?.background_color]) + + const isMediaReady = slide?.type === 'video' ? videoLoaded : imageLoaded + + // Log when component renders to track re-renders + if (__DEV__) { + const aspectRatioFromRef = videoAspectRatioRef.current + console.log(`[StorySlide] Component render - slide: ${slide?.id}, resizeMode: ${videoResizeMode}, aspectRatio(ref): ${aspectRatioFromRef}, isMediaReady: ${isMediaReady}`) + } + + return ( + + {/* Media content - always render but hide until loaded */} + {slide && backgroundElement} + + {/* Loading indicator - show on top while loading, with solid background */} + {mediaLoading && ( + + + + + )} + + {/* Volume button - only for video slides */} + {slide?.type === 'video' && isMediaReady && ( + + + + )} + + {/* Interactive elements overlay - only show when media is loaded */} + {isMediaReady && slide?.elements && slide.elements.length > 0 && ( + + )} + + ) +} + +// Memoize component to prevent unnecessary re-renders when only progress updates +export default React.memo(StorySlide, (prevProps, nextProps) => { + // Only re-render if these props actually changed + return ( + prevProps.slide?.id === nextProps.slide?.id && + prevProps.isActive === nextProps.isActive && + prevProps.slide?.background === nextProps.slide?.background && + prevProps.slide?.backgroundColor === nextProps.slide?.backgroundColor && + prevProps.slide?.elements?.length === nextProps.slide?.elements?.length + ) +}) diff --git a/components/Stories/StoryTimeline.js b/components/Stories/StoryTimeline.js new file mode 100644 index 0000000..b151fe9 --- /dev/null +++ b/components/Stories/StoryTimeline.js @@ -0,0 +1,345 @@ +// FILE VERSION: 2024-12-19-FIX-PROGRESS-BAR-V2 +// Note: All progress bars have equal width (fillEqually like iOS) - duration affects fill speed via progress prop, not bar width + +import React, { useRef, useState, useEffect, useMemo } from 'react' +import { View, StyleSheet, Dimensions, Animated, Platform } from 'react-native' +import { DEFAULT_COLORS, DEFAULT_CONFIG } from './styles' + +const { width: screenWidth } = Dimensions.get('window') + +/** + * StoryTimeline Component + * Timeline with progress bars for all slides, similar to UIStackView with UIProgressView in iOS SDK + * All progress bars have equal width (fillEqually distribution) - duration affects fill speed via progress prop + * + * @param {Object} props + * @param {Array} props.slides - Array of slide objects + * @param {number} props.currentSlideIndex - Index of currently active slide + * @param {number} props.currentProgress - Progress of current slide (0-1), speed depends on slide duration + * @param {string} props.backgroundColor - Background color for progress bars + */ +export default function StoryTimeline({ + slides = [], + currentSlideIndex = 0, + currentProgress = 0, + backgroundColor = DEFAULT_COLORS.backgroundProgress, +}) { + const wrapperRef = useRef(null) + const [wrapperWidth, setWrapperWidth] = useState(0) + // Store Animated.Value for each slide's fill width + const animatedWidthsRef = useRef({}) + + // Calculate bar width in pixels (like iOS UIStackView with fillEqually distribution) + // All bars have equal width - duration affects fill speed, not bar width + const calculateBarWidth = (wrapperWidth, slidesCount) => { + if (slidesCount === 0 || wrapperWidth === 0) return 0 + const marginBetweenBars = DEFAULT_CONFIG.progressBarMargin * 2 // marginHorizontal * 2 for each bar + const totalMargin = (slidesCount - 1) * marginBetweenBars + return (wrapperWidth - totalMargin) / slidesCount + } + + // Calculate actual bar width - use state value or fallback + const effectiveWidth = wrapperWidth > 0 ? wrapperWidth : (screenWidth - 32 - 12 - 30) // progressContainer padding + closeButton + const actualBarWidth = calculateBarWidth(effectiveWidth, slides.length) + + // Clamp progress between 0 and 1 + const clampedProgress = Math.max(0, Math.min(1, currentProgress)) + + // Initialize animated values for slides - ensure they start at 0 + useEffect(() => { + slides.forEach((slide, index) => { + const slideKey = slide.id || index + if (!animatedWidthsRef.current[slideKey]) { + animatedWidthsRef.current[slideKey] = new Animated.Value(0) + } else { + // CRITICAL: Reset to 0 if it's not the current slide OR if it's the current slide but progress is 0 + // This ensures slides start at 0 when they become current + if (index !== currentSlideIndex) { + const currentValue = animatedWidthsRef.current[slideKey]._value || 0 + if (currentValue > 0) { + animatedWidthsRef.current[slideKey].setValue(0) + } + } else if (index === currentSlideIndex && currentProgress === 0) { + // CRITICAL: If this is the current slide and progress is 0, ensure animated value is also 0 + const currentValue = animatedWidthsRef.current[slideKey]._value || 0 + if (currentValue > 0) { + animatedWidthsRef.current[slideKey].setValue(0) + } + } + } + }) + }, [slides, currentSlideIndex, currentProgress]) + + // Reset animated value when slide changes and animate current slide + const prevSlideIndexRef = useRef(-1) // Initialize to -1 to detect first slide change + const prevProgressRef = useRef(currentProgress) + + // CRITICAL: Use useMemo to ensure actualBarWidth and clampedProgress are stable + const memoizedActualBarWidth = useMemo(() => actualBarWidth, [actualBarWidth]) + const memoizedClampedProgress = useMemo(() => clampedProgress, [clampedProgress]) + + // CRITICAL: Separate effect to reset animated value when slide changes + useEffect(() => { + if (currentSlideIndex >= 0 && currentSlideIndex < slides.length) { + const slide = slides[currentSlideIndex] + const slideKey = slide.id || currentSlideIndex + + // Ensure animated value exists + if (!animatedWidthsRef.current[slideKey]) { + animatedWidthsRef.current[slideKey] = new Animated.Value(0) + } + + // CRITICAL: If slide changed, reset to 0 IMMEDIATELY + if (prevSlideIndexRef.current !== currentSlideIndex) { + const animatedValue = animatedWidthsRef.current[slideKey] + if (animatedValue) { + animatedValue.stopAnimation() + animatedValue.setValue(0) + } + + // Reset previous slide + if (prevSlideIndexRef.current >= 0 && prevSlideIndexRef.current < slides.length) { + const prevSlide = slides[prevSlideIndexRef.current] + const prevSlideKey = prevSlide.id || prevSlideIndexRef.current + const prevAnimatedValue = animatedWidthsRef.current[prevSlideKey] + if (prevAnimatedValue) { + prevAnimatedValue.stopAnimation() + prevAnimatedValue.setValue(0) + } + } + + prevSlideIndexRef.current = currentSlideIndex + } + } + }, [currentSlideIndex, slides.length]) + + useEffect(() => { + // Ensure we have valid slide index + if (currentSlideIndex < 0 || currentSlideIndex >= slides.length) { + return + } + + const slide = slides[currentSlideIndex] + const slideKey = slide.id || currentSlideIndex + + // Ensure animated value exists and is initialized to 0 + if (!animatedWidthsRef.current[slideKey]) { + animatedWidthsRef.current[slideKey] = new Animated.Value(0) + } + + const animatedValue = animatedWidthsRef.current[slideKey] + + // CRITICAL: If slide changed, reset to 0 FIRST before any animation + if (prevSlideIndexRef.current !== currentSlideIndex) { + // Stop any ongoing animations + animatedValue.stopAnimation() + animatedValue.removeAllListeners() + + // Reset previous slide's animated value to 0 + if (prevSlideIndexRef.current >= 0 && prevSlideIndexRef.current < slides.length) { + const prevSlide = slides[prevSlideIndexRef.current] + const prevSlideKey = prevSlide.id || prevSlideIndexRef.current + const prevAnimatedValue = animatedWidthsRef.current[prevSlideKey] + if (prevAnimatedValue) { + prevAnimatedValue.stopAnimation() + prevAnimatedValue.setValue(0) + } + } + + // CRITICAL: Reset current slide to 0 when switching - this ensures it starts from 0 + animatedValue.setValue(0) + + prevSlideIndexRef.current = currentSlideIndex + prevProgressRef.current = 0 + } + + // Only animate if we have valid dimensions + if (memoizedActualBarWidth > 0 && wrapperWidth > 0) { + // Calculate target width based on progress + const targetWidth = Math.max(0, memoizedActualBarWidth * memoizedClampedProgress) + const currentWidth = animatedValue._value || 0 + + // Always update if width changed (even by 0.1px) or progress changed + const progressChanged = Math.abs(prevProgressRef.current - currentProgress) > 0.001 + const widthChanged = Math.abs(targetWidth - currentWidth) > 0.1 + + if (widthChanged || progressChanged) { + // Stop any ongoing animation and remove old listeners + animatedValue.stopAnimation() + animatedValue.removeAllListeners() + + Animated.timing(animatedValue, { + toValue: targetWidth, + duration: 50, // Fast animation for smooth progress + useNativeDriver: false, // width animation requires JS driver + }).start() + + prevProgressRef.current = currentProgress + } + } + }, [currentProgress, currentSlideIndex, memoizedActualBarWidth, memoizedClampedProgress, wrapperWidth, slides.length]) + + + // Get wrapper width from layout - use state to trigger re-render + const handleLayout = (event) => { + const { width } = event.nativeEvent.layout + if (width > 0 && width !== wrapperWidth) { + setWrapperWidth(width) + } + } + + return ( + + {slides.map((slide, index) => { + // Determine progress value for each bar (like iOS: 0, 1, or current progress) + let progressValue = 0 + if (index < currentSlideIndex) { + progressValue = 1 // Viewed slides - 100% (like iOS UIProgressView.progress = 1) + } else if (index === currentSlideIndex) { + progressValue = clampedProgress // Current slide - actual progress (0-1) + } else { + progressValue = 0 // Future slides - 0% (like iOS UIProgressView.progress = 0) + } + + const isCurrentSlide = index === currentSlideIndex + + // CRITICAL: Calculate fill width - this MUST be correct + // All bars have equal width (fillEqually like iOS) - duration affects fill speed, not bar width + let fillWidth = 0 + if (actualBarWidth > 0 && wrapperWidth > 0) { + if (isCurrentSlide) { + // For current slide: calculate based on progressValue + if (progressValue > 0) { + const rawWidth = actualBarWidth * progressValue + fillWidth = Math.max(1, Math.round(rawWidth)) // At least 1px when progress > 0 + } else { + fillWidth = 0 // 0 when progress = 0 + } + } else { + // For other slides: use progressValue directly + fillWidth = Math.max(0, Math.round(actualBarWidth * progressValue)) + } + } + + const slideKey = slide.id || index + + // Background color: white with transparency for all slides (inactive and active) + // Always use white with transparency from DEFAULT_COLORS, ignore passed backgroundColor parameter + const barBgColor = DEFAULT_COLORS.backgroundProgress // White with transparency for all slides + + // Always render fill for current slide to ensure it updates + // For other slides, only render if progress > 0 + const shouldRenderFill = fillWidth > 0 || isCurrentSlide + + const barWidth = actualBarWidth > 0 ? actualBarWidth : 0 + + // CRITICAL: Force white with transparency background color for all slides + const finalBgColor = 'rgba(255, 255, 255, 0.3)' + + const progressBarElement = ( + + {/* Render fill for current slide using Animated.View for smooth width animation */} + {isCurrentSlide && (() => { + // CRITICAL: Create animated value synchronously if it doesn't exist + if (!animatedWidthsRef.current[slideKey]) { + animatedWidthsRef.current[slideKey] = new Animated.Value(0) + } + + const animatedValue = animatedWidthsRef.current[slideKey] + + // CRITICAL: If progress is 0, force reset animated value to 0 + const currentAnimatedValue = animatedValue._value || 0 + if (progressValue === 0 && clampedProgress === 0 && currentAnimatedValue > 0) { + animatedValue.setValue(0) + } + + return ( + + ) + })()} + {/* Render fill for completed slides */} + {!isCurrentSlide && fillWidth > 0 && actualBarWidth > 0 && ( + + )} + + ) + + return progressBarElement + })} + + ) +} + +const styles = StyleSheet.create({ + timelineWrapper: { + flex: 1, + flexDirection: 'row', + marginRight: 12, + }, + progressBar: { + height: DEFAULT_CONFIG.progressBarHeight, + borderRadius: DEFAULT_CONFIG.progressBarHeight / 2, + backgroundColor: 'rgba(255, 255, 255, 0.3)', // White with transparency - will be overridden by inline style if needed + marginHorizontal: DEFAULT_CONFIG.progressBarMargin, + overflow: 'hidden', + position: 'relative', + }, + progressFill: { + position: 'absolute', + top: 0, + left: 0, + height: '100%', + backgroundColor: DEFAULT_COLORS.text, // White fill + borderRadius: DEFAULT_CONFIG.progressBarHeight / 2, + minWidth: 0, // Allow width to be 0, but ensure it updates + zIndex: 1, // Ensure fill is above background + // Add border to make fill more visible for debugging + borderWidth: 0.5, + borderColor: 'rgba(0, 0, 0, 0.1)', + }, +}) diff --git a/components/Stories/StoryViewer.js b/components/Stories/StoryViewer.js new file mode 100644 index 0000000..2d80bae --- /dev/null +++ b/components/Stories/StoryViewer.js @@ -0,0 +1,1224 @@ +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { + View, + Modal, + Text, + Pressable, + Dimensions, + PanResponder, + Animated, + Platform, + Linking, + ActivityIndicator, +} from 'react-native' +import StorySlide from './StorySlide' +import StoryTimeline from './StoryTimeline.js' +import ProductsCarousel from './ProductsCarousel' +import { styles, getDuration, getStartSlideIndex, extractNumericId, extractSlideIdForTracking, getColorFromSettings, DEFAULT_COLORS } from './styles' +import { markSlideAsViewed, getStartSlideIndex as getStorageStartIndex, setLastSeenSlide } from '../../lib/stories/storage' +import { isSlidePreloaded, preloadSlide, onSlideReady, PRIORITY } from '../../lib/stories/slidePreloader' + +const { width: screenWidth, height: screenHeight } = Dimensions.get('window') + +/** + * StoryViewer Component + * Full-screen modal for viewing stories with navigation and progress tracking + * + * @param {Object} props + * @param {boolean} props.visible - Whether modal is visible + * @param {Story[]} props.stories - Array of stories + * @param {number} props.initialStoryIndex - Initial story index to start with + * @param {number} [props.initialSlideIndex] - Initial slide index within story + * @param {Object} [props.settings] - Stories settings from API (colors, etc.) + * @param {Function} props.onClose - Callback when viewer is closed + * @param {Object} props.sdk - SDK instance + * @param {string} props.code - Stories code identifier + * @param {Function} [props.onElementPress] - Callback when element is pressed + */ +export default function StoryViewer({ + visible, + stories, + initialStoryIndex, + initialSlideIndex, + settings, + onClose, + sdk, + code, + onElementPress, +}) { + const [currentStoryIndex, setCurrentStoryIndex] = useState(initialStoryIndex || 0) + const [currentSlideIndex, setCurrentSlideIndex] = useState(0) + const [isPaused, setIsPaused] = useState(false) + const [progress, setProgress] = useState(0) + const [mediaLoaded, setMediaLoaded] = useState(false) + const [mediaPreloaded, setMediaPreloaded] = useState(false) // Track if media is preloaded + + // Carousel state + const [carouselVisible, setCarouselVisible] = useState(false) + const [carouselProducts, setCarouselProducts] = useState([]) + + const [carouselHideLabel, setCarouselHideLabel] = useState('Скрыть') + + const timerRef = useRef(null) + const progressTimerRef = useRef(null) + const longPressTimerRef = useRef(null) + const isLongPressingRef = useRef(false) + + // Use refs for functions used in timers to avoid recreating callbacks + const nextSlideRef = useRef(null) + const nextStoryRef = useRef(null) + const clearTimersRef = useRef(null) + + // Use ref for isPaused to always have current value inside timer + const isPausedRef = useRef(false) + + // Store current progress step when paused to resume from same position + const pausedProgressStepRef = useRef(0) + + // Use ref for progress to always have current value + const progressRef = useRef(0) + + // Use refs for current story and slide index to always have current values + const currentStoryRef = useRef(null) + const currentSlideRef = useRef(null) + const currentStoryIndexRef = useRef(initialStoryIndex || 0) + const currentSlideIndexRef = useRef(0) + const mediaLoadedRef = useRef(false) + const storiesRef = useRef(stories) + const startProgressTimerRef = useRef(null) + const elementPressTimeoutRef = useRef(null) + const handleLongPressStartRef = useRef(null) + const handleLongPressEndRef = useRef(null) + + // Animation for swipe down to close + const translateY = useRef(new Animated.Value(0)).current + const opacity = useRef(new Animated.Value(1)).current + const backdropOpacity = useRef(new Animated.Value(1)).current // Backdrop opacity for host screen visibility + const isClosingRef = useRef(false) + + // Memoize current story and slide to avoid recalculation + const currentStory = useMemo(() => stories?.[currentStoryIndex], [stories, currentStoryIndex]) + const currentSlide = useMemo(() => currentStory?.slides?.[currentSlideIndex], [currentStory, currentSlideIndex]) + + // Memoize slide IDs to avoid recreating array on each render + const slideIds = useMemo(() => { + return currentStory?.slides?.map(slide => slide.id) || [] + }, [currentStory]) + + // Update currentStoryIndex when visible becomes true or initialStoryIndex changes + useEffect(() => { + if (visible) { + const targetIndex = initialStoryIndex ?? 0 + // Always sync ref/state when opening to avoid stale index + currentStoryIndexRef.current = targetIndex + setCurrentStoryIndex(targetIndex) + // Reset slide index refs/state immediately; start index will be set after storage lookup + currentSlideIndexRef.current = 0 + setCurrentSlideIndex(0) + setProgress(0) + progressRef.current = 0 + pausedProgressStepRef.current = 0 + setIsPaused(false) + isPausedRef.current = false + clearTimers() + } + }, [visible, initialStoryIndex]) + + // Reset slide index when story index changes to avoid stale progress state + // Use ref to track previous story index to avoid resetting on first open + const previousStoryIndexRef = useRef(currentStoryIndex) + useEffect(() => { + if (!visible) return + // Only reset if story actually changed (not on first open) + if (previousStoryIndexRef.current !== currentStoryIndex) { + currentSlideIndexRef.current = 0 + setCurrentSlideIndex(0) + setProgress(0) + pausedProgressStepRef.current = 0 + setIsPaused(false) + previousStoryIndexRef.current = currentStoryIndex + } + }, [visible, currentStoryIndex]) + + useEffect(() => { + if (visible && currentStory && slideIds.length > 0) { + // Reset media loaded state when story changes + setMediaLoaded(false) + clearTimers() + // Reset to a safe default immediately to avoid stale ref usage + const defaultIndex = initialSlideIndex || currentStory.startPosition || 0 + const maxIndex = slideIds.length - 1 + const safeDefaultIndex = Math.min(Math.max(defaultIndex, 0), maxIndex) + currentSlideIndexRef.current = safeDefaultIndex + setCurrentSlideIndex(safeDefaultIndex) + setProgress(0) + progressRef.current = 0 + pausedProgressStepRef.current = 0 + setIsPaused(false) + isPausedRef.current = false + + const storyId = currentStory.id + let isActive = true + + // Get starting slide index from storage + getStorageStartIndex(storyId, slideIds, initialSlideIndex || currentStory.startPosition || 0) + .then(startIndex => { + if (!isActive || currentStoryRef.current?.id !== storyId) { + return + } + const safeStartIndex = Math.min(Math.max(startIndex, 0), maxIndex) + // Sync ref/state immediately to avoid mismatch with progress/handlers + currentSlideIndexRef.current = safeStartIndex + setCurrentSlideIndex(safeStartIndex) + // Reset progress and pause state for new start + setProgress(0) + progressRef.current = 0 + pausedProgressStepRef.current = 0 + setIsPaused(false) + isPausedRef.current = false + // Don't start timer yet - wait for media to load + }) + .catch(error => { + if (!isActive || currentStoryRef.current?.id !== storyId) { + return + } + console.warn('[StoryViewer] Error getting start slide index:', error) + // Fallback to default + const fallbackIndex = initialSlideIndex || currentStory.startPosition || 0 + const safeFallbackIndex = Math.min(Math.max(fallbackIndex, 0), maxIndex) + currentSlideIndexRef.current = safeFallbackIndex + setCurrentSlideIndex(safeFallbackIndex) + setProgress(0) + progressRef.current = 0 + pausedProgressStepRef.current = 0 + setIsPaused(false) + isPausedRef.current = false + }) + + return () => { + isActive = false + } + } + }, [visible, currentStoryIndex, currentStory, slideIds, initialSlideIndex]) + + // Check if current slide media is preloaded + // For video slides: show immediately (streaming), don't wait for full preload + // For image slides: check preload status, show loading indicator if not ready + useEffect(() => { + if (visible && currentSlide) { + if (__DEV__) { + console.log(`[StoryViewer] Checking preload for slide ${currentSlide.id}, type: ${currentSlide.type}`) + } + // For video slides, show immediately - video will load and play via streaming + if (currentSlide.type === 'video') { + // Always show video slides immediately - don't wait for preload + if (__DEV__) { + console.log(`[StoryViewer] Video slide - setting mediaPreloaded=true immediately`) + } + setMediaPreloaded(true) + // Start preloading in background for better performance (optional) + preloadSlide(currentSlide, PRIORITY.HIGH).catch(() => { + // Silent fail - video will load via streaming anyway + }) + } else { + // For image slides, check if preloaded + setMediaPreloaded(false) + isSlidePreloaded(currentSlide.id).then(preloaded => { + if (preloaded) { + setMediaPreloaded(true) + } else { + // If not preloaded, request high priority preload and wait for it + preloadSlide(currentSlide, PRIORITY.HIGH).then(() => { + // Wait for slide to be ready + const unsubscribe = onSlideReady(currentSlide.id, () => { + setMediaPreloaded(true) + unsubscribe() + }) + }).catch(() => { + // If preload fails, show anyway after a short delay + setTimeout(() => { + setMediaPreloaded(true) + }, 500) + }) + } + }).catch(() => { + // If check fails, show anyway after a short delay + setTimeout(() => { + setMediaPreloaded(true) + }, 500) + }) + } + } else if (!visible) { + // Reset when modal closes + setMediaPreloaded(false) + } + }, [visible, currentSlide?.id, currentSlide?.type]) + + useEffect(() => { + if (visible) { + // Reset media loaded state and progress when slide changes + setMediaLoaded(false) + // Don't reset mediaPreloaded here - let the preload check useEffect handle it + // This prevents race condition where video slides get stuck on loading + setProgress(0) // Reset progress immediately when slide changes + progressRef.current = 0 + pausedProgressStepRef.current = 0 // Reset paused step when slide changes + setIsPaused(false) // Reset pause state when slide changes + isPausedRef.current = false + clearTimers() + // Clear video duration for previous slide (will be set when new video loads) + + // Preload next slide with medium priority + if (currentStory?.slides) { + const nextSlideIndex = currentSlideIndex + 1 + if (nextSlideIndex < currentStory.slides.length) { + const nextSlide = currentStory.slides[nextSlideIndex] + if (nextSlide) { + preloadSlide(nextSlide, PRIORITY.MEDIUM) + } + } + } + + // Clear any pending element press timeout + if (elementPressTimeoutRef.current) { + clearTimeout(elementPressTimeoutRef.current) + elementPressTimeoutRef.current = null + } + } + + return () => { + clearTimers() + // Cleanup timeout on unmount + if (elementPressTimeoutRef.current) { + clearTimeout(elementPressTimeoutRef.current) + elementPressTimeoutRef.current = null + } + } + }, [visible, currentSlide?.id, currentSlideIndex, currentStory, clearTimers]) + + // Reset progress and media loaded when slide index changes (e.g., when resuming from storage) + useEffect(() => { + if (visible) { + setMediaLoaded(false) + setProgress(0) + progressRef.current = 0 + pausedProgressStepRef.current = 0 + clearTimers() + } + }, [currentSlideIndex, visible, clearTimers]) + + useEffect(() => { + if (visible && currentSlide && mediaLoaded) { + // Track slide view only after media is loaded + if (sdk && code) { + const storyIdToUse = extractNumericId(currentStory.id, currentStory.ids) + // If slide.id is string, use slideIndex; if number, use slide.id as is + const slideIdToUse = extractSlideIdForTracking(currentSlide.id, currentSlideIndex) + + sdk.trackStoryView(storyIdToUse, slideIdToUse, code, currentSlideIndex) + } + + // Don't mark slide as viewed immediately - wait for it to complete or be navigated away + } + }, [currentSlide, mediaLoaded, currentSlideIndex]) + + useEffect(() => { + if (!visible) { + // Save last seen slide when closing + const story = currentStoryRef.current + const slideIndex = currentSlideIndexRef.current + + if (story && story.slides && slideIndex >= 0 && slideIndex < story.slides.length) { + const currentSlideToSave = story.slides[slideIndex] + if (currentSlideToSave) { + setLastSeenSlide(story.id, currentSlideToSave.id) + } + } + + // Mark current slide as viewed when closing (like iOS didEndDisplaying) + if (story && story.slides && slideIndex >= 0 && slideIndex < story.slides.length) { + const currentSlideToMark = story.slides[slideIndex] + if (currentSlideToMark) { + markSlideAsViewed(story.id, currentSlideToMark.id) + } + } + + clearTimers() + setMediaLoaded(false) + // Reset animation when modal closes + translateY.setValue(0) + opacity.setValue(1) + backdropOpacity.setValue(1) + isClosingRef.current = false + } else { + // Reset animation when modal opens + translateY.setValue(0) + opacity.setValue(1) + backdropOpacity.setValue(1) + isClosingRef.current = false + // Reset pause state and progress when opening + setIsPaused(false) + isPausedRef.current = false // Also reset ref + setProgress(0) + pausedProgressStepRef.current = 0 + setMediaLoaded(false) // Reset media loaded to trigger reload + } + }, [visible]) + + // Update refs when state changes + useEffect(() => { + isPausedRef.current = isPaused + progressRef.current = progress + currentStoryRef.current = currentStory + currentSlideRef.current = currentSlide + currentStoryIndexRef.current = currentStoryIndex + currentSlideIndexRef.current = currentSlideIndex + mediaLoadedRef.current = mediaLoaded + storiesRef.current = stories + }, [isPaused, progress, currentStory, currentSlide, currentStoryIndex, currentSlideIndex, mediaLoaded, stories]) + + // Guard: keep slide index within bounds of current story + useEffect(() => { + if (!visible || !currentStory?.slides?.length) return + const maxIndex = currentStory.slides.length - 1 + if (currentSlideIndex < 0 || currentSlideIndex > maxIndex) { + const safeIndex = Math.min(Math.max(currentSlideIndex, 0), maxIndex) + if (__DEV__) { + console.warn('[StoryViewer] Clamping slide index:', { + currentSlideIndex, + safeIndex, + maxIndex, + storyId: currentStory.id, + }) + } + currentSlideIndexRef.current = safeIndex + setCurrentSlideIndex(safeIndex) + setProgress(0) + pausedProgressStepRef.current = 0 + setIsPaused(false) + } + }, [visible, currentStory, currentSlideIndex]) + + // Start/restart timer when slide changes, media loads, or pause state changes + useEffect(() => { + if (visible && currentSlide && mediaLoaded && !isPaused) { + // For video slides, ensure we have video duration before starting timer + // This prevents timer from starting before video metadata is loaded + if (currentSlide.type === 'video') { + const videoDuration = videoDurationsRef.current[currentSlide.id] + // If video duration is not available yet, wait for it + // Timer will restart when duration arrives via handleVideoDuration + if (!videoDuration || videoDuration <= 0) { + if (__DEV__) { + console.log(`[StoryViewer] Waiting for video duration before starting timer for slide ${currentSlide.id}`) + } + return + } + } + startProgressTimer() + } else if (isPaused || !mediaLoaded) { + clearTimers() + } + }, [isPaused, mediaLoaded, visible, currentSlide, currentSlideIndex]) + + const clearTimers = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + if (progressTimerRef.current) { + clearInterval(progressTimerRef.current) + progressTimerRef.current = null + } + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + }, []) + + // Update refs when functions change + useEffect(() => { + clearTimersRef.current = clearTimers + nextSlideRef.current = nextSlide + nextStoryRef.current = nextStory + startProgressTimerRef.current = startProgressTimer + handleLongPressStartRef.current = handleLongPressStart + handleLongPressEndRef.current = handleLongPressEnd + }, [clearTimers, nextSlide, nextStory, startProgressTimer, handleLongPressStart, handleLongPressEnd]) + + const handleMediaLoaded = useCallback(() => { + setMediaLoaded(true) + }, []) + + // Store video duration for slides that don't have duration set + const videoDurationsRef = useRef({}) // slideId -> duration in seconds + + const handleVideoProgress = useCallback((currentTime, slideId) => { + // For video slides, use video playback time as the source of truth for progress + // currentTime is in seconds + if (currentSlideRef.current?.id === slideId && currentSlideRef.current?.type === 'video') { + const videoDuration = videoDurationsRef.current[slideId] + if (videoDuration && videoDuration > 0) { + // Calculate progress based on actual video playback time + const progress = Math.min(1, currentTime / videoDuration) + // Update progress to match video playback - this is the authoritative source for video slides + setProgress(progress) + progressRef.current = progress + } + } + }, []) + + const handleVideoDuration = useCallback((duration, slideId) => { + // Store video duration for the slide (duration is in seconds) + if (slideId && duration > 0 && typeof duration === 'number' && !isNaN(duration) && isFinite(duration)) { + videoDurationsRef.current[slideId] = duration + if (__DEV__) { + console.log(`[StoryViewer] Video duration stored: ${duration.toFixed(3)}s for slide ${slideId}, currentSlide=${currentSlideRef.current?.id}, mediaLoaded=${mediaLoadedRef.current}`) + } + // If this is the current slide and media is loaded, restart timer with correct duration + if (currentSlideRef.current?.id === slideId && mediaLoadedRef.current && !isPausedRef.current) { + if (__DEV__) { + console.log(`[StoryViewer] Restarting timer with video duration: ${duration.toFixed(3)}s for slide ${slideId}`) + } + // Reset progress when restarting with correct duration + setProgress(0) + progressRef.current = 0 + pausedProgressStepRef.current = 0 + // Restart timer with correct duration using ref to avoid dependency + startProgressTimerRef.current?.() + } + } + }, []) + + const startProgressTimer = useCallback(() => { + if (!currentSlide || isPausedRef.current || !mediaLoadedRef.current) { + if (__DEV__) { + console.log(`[StoryViewer] Timer blocked: currentSlide=${!!currentSlide}, isPaused=${isPausedRef.current}, mediaLoaded=${mediaLoadedRef.current}`) + } + return + } + + // For video slides, ensure video is actually ready (not just metadata loaded) + if (currentSlide.type === 'video') { + // Additional check: video should be loaded and ready + // This prevents timer from starting before video is ready to play + if (!mediaLoadedRef.current) { + if (__DEV__) { + console.log(`[StoryViewer] Timer blocked for video: media not loaded yet`) + } + return + } + } + + // Always clear existing timers first + clearTimers() + + // For video slides, always use duration from video file metadata + // For other slides, use duration from API response + let duration + if (currentSlide.type === 'video') { + const videoDuration = videoDurationsRef.current[currentSlide.id] + if (videoDuration && videoDuration > 0 && typeof videoDuration === 'number' && !isNaN(videoDuration) && isFinite(videoDuration)) { + // Convert from seconds to milliseconds - always use video file duration + duration = videoDuration * 1000 + if (__DEV__) { + console.log(`[StoryViewer] Using video duration: ${videoDuration.toFixed(3)}s (${duration}ms) for slide ${currentSlide.id}`) + } + } else { + // Video duration not loaded yet - wait for it (timer will restart when duration arrives) + // Use default duration temporarily to avoid timer issues + duration = 10000 // Default 10 seconds, will be updated when video metadata loads + if (__DEV__) { + console.log(`[StoryViewer] Video duration not available yet for slide ${currentSlide.id}, using default: ${duration}ms`) + } + } + } else { + // For non-video slides, use duration from API response + duration = getDuration(currentSlide) + if (__DEV__) { + console.log(`[StoryViewer] Using API duration: ${duration}ms for slide ${currentSlide.id}`) + } + } + const progressInterval = 50 // Update every 50ms + const totalSteps = duration / progressInterval + + if (__DEV__) { + if (__DEV__) { + console.log(`[ProgressTimer] Starting: duration=${duration}, totalSteps=${totalSteps}, slideId=${currentSlide.id}`) + } + } + + // Use saved progress step if resuming from pause, otherwise start from 0 + let currentStep = pausedProgressStepRef.current + + // If resuming from pause, restore progress value + if (currentStep > 0) { + const savedProgress = currentStep / totalSteps + setProgress(savedProgress) + if (__DEV__) { + } + } else { + // Starting fresh - reset progress to 0 + setProgress(0) + if (__DEV__) { + } + } + + progressTimerRef.current = setInterval(() => { + // Check if paused inside the timer - if paused, save current step and clear timer + if (isPausedRef.current) { + pausedProgressStepRef.current = currentStep + if (progressTimerRef.current) { + clearInterval(progressTimerRef.current) + progressTimerRef.current = null + } + return + } + + // For video slides, don't update progress from timer - use onProgress from video instead + // This prevents conflicts between timer and video playback progress + if (currentSlide.type === 'video') { + // Only increment step for completion check, but don't update progress + // Progress is updated by handleVideoProgress from video onProgress event + currentStep++ + const newProgress = currentStep / totalSteps + + if (newProgress >= 1) { + // Timer reached end - move to next slide + // But for video, we rely on onEnd event, so this is just a fallback + clearTimersRef.current?.() + nextSlideRef.current?.() + } + return + } + + // For non-video slides, update progress normally + currentStep++ + const newProgress = currentStep / totalSteps + setProgress(newProgress) + progressRef.current = newProgress + + if (__DEV__ && currentStep % 20 === 0) { + console.log(`[ProgressTimer] Update: step=${currentStep}, progress=${newProgress.toFixed(3)}`) + } + + if (newProgress >= 1) { + // Mark slide as viewed when it completes (like iOS didEndDisplaying) + if (currentStoryRef.current && currentSlide) { + markSlideAsViewed(currentStoryRef.current.id, currentSlide.id) + } + + // Reset paused step when slide completes + pausedProgressStepRef.current = 0 + clearTimersRef.current?.() + nextSlideRef.current?.() + } + }, progressInterval) + }, [currentSlide, clearTimers]) + + const nextSlide = useCallback(() => { + // Save last seen slide before navigating away + const story = currentStoryRef.current + const slideIndex = currentSlideIndexRef.current + + if (story && story.slides && slideIndex >= 0 && slideIndex < story.slides.length) { + const currentSlideToSave = story.slides[slideIndex] + if (currentSlideToSave) { + setLastSeenSlide(story.id, currentSlideToSave.id) + } + } + + // Mark current slide as viewed when navigating away (like iOS didEndDisplaying) + if (story && story.slides && slideIndex >= 0 && slideIndex < story.slides.length) { + const currentSlideToMark = story.slides[slideIndex] + if (currentSlideToMark) { + markSlideAsViewed(story.id, currentSlideToMark.id) + } + } + + // Reset state before changing slide + setMediaLoaded(false) + setProgress(0) + pausedProgressStepRef.current = 0 // Reset paused step when changing slides + clearTimers() + + if (!story || !story.slides) { + if (__DEV__) { + console.warn('[StoryViewer] Cannot go to next slide: story is undefined') + } + return + } + + if (slideIndex < story.slides.length - 1) { + const nextIndex = slideIndex + 1 + currentSlideIndexRef.current = nextIndex + setCurrentSlideIndex(nextIndex) + } else { + nextStoryRef.current?.() + } + }, [clearTimers, nextStory]) + + const previousSlide = useCallback(() => { + // Save last seen slide before navigating away + const story = currentStoryRef.current + const slideIndex = currentSlideIndexRef.current + + if (story && story.slides && slideIndex >= 0 && slideIndex < story.slides.length) { + const currentSlideToSave = story.slides[slideIndex] + if (currentSlideToSave) { + setLastSeenSlide(story.id, currentSlideToSave.id) + } + } + + // Reset state before changing slide + setMediaLoaded(false) + setProgress(0) + pausedProgressStepRef.current = 0 // Reset paused step when changing slides + clearTimers() + + if (slideIndex > 0) { + const prevIndex = slideIndex - 1 + currentSlideIndexRef.current = prevIndex + setCurrentSlideIndex(prevIndex) + } else { + previousStory() + } + }, [clearTimers, previousStory]) + + const nextStory = useCallback(() => { + // Save last seen slide before moving to next story + const story = currentStoryRef.current + const slideIndex = currentSlideIndexRef.current + + if (story && story.slides && slideIndex >= 0 && slideIndex < story.slides.length) { + const currentSlideToSave = story.slides[slideIndex] + if (currentSlideToSave) { + setLastSeenSlide(story.id, currentSlideToSave.id) + } + } + + // Mark current slide as viewed when moving to next story + if (story && story.slides && slideIndex >= 0 && slideIndex < story.slides.length) { + const currentSlideToMark = story.slides[slideIndex] + if (currentSlideToMark) { + markSlideAsViewed(story.id, currentSlideToMark.id) + } + } + + const storyIndex = currentStoryIndexRef.current + // Use ref for stories as it's updated in useEffect and contains the latest value + // Props might be empty initially when modal opens + const storiesArray = storiesRef.current || stories + + // Check if there's a next story available - prefer ref over props + if (storiesArray && Array.isArray(storiesArray) && storiesArray.length > 0) { + const nextStoryIndex = storyIndex + 1 + if (nextStoryIndex < storiesArray.length) { + currentStoryIndexRef.current = nextStoryIndex + currentSlideIndexRef.current = 0 + setCurrentStoryIndex(nextStoryIndex) + setCurrentSlideIndex(0) + return + } + } + + // No more stories, close viewer + onCloseRef.current?.() + }, [stories, onClose, currentStoryIndex]) + + const previousStory = () => { + // Save last seen slide before moving to previous story + const story = currentStoryRef.current + const slideIndex = currentSlideIndexRef.current + const storyIndex = currentStoryIndexRef.current + // Use ref for stories as it's updated in useEffect and contains the latest value + // Props might be empty initially when modal opens + const storiesArray = storiesRef.current || stories + + if (story && story.slides && slideIndex >= 0 && slideIndex < story.slides.length) { + const currentSlideToSave = story.slides[slideIndex] + if (currentSlideToSave) { + setLastSeenSlide(story.id, currentSlideToSave.id) + } + } + + if (storiesArray && Array.isArray(storiesArray) && storiesArray.length > 0 && storyIndex > 0) { + const prevStoryIndex = storyIndex - 1 + const prevStory = storiesArray[prevStoryIndex] + if (prevStory && prevStory.slides && prevStory.slides.length > 0) { + currentStoryIndexRef.current = prevStoryIndex + currentSlideIndexRef.current = prevStory.slides.length - 1 + setCurrentStoryIndex(prevStoryIndex) + setCurrentSlideIndex(prevStory.slides.length - 1) + } else { + if (__DEV__) { + console.warn('[StoryViewer] Previous story not found or has no slides') + } + onClose?.() + } + } else { + // No previous story, close viewer + onClose?.() + } + } + + const handleElementPress = useCallback((element) => { + // Pause timer when element is pressed + setIsPaused(true) + clearTimers() + + // Track click event for slide element - use refs for current values + if (sdk && code) { + const story = currentStoryRef.current + const slide = currentSlideRef.current || currentSlide + const slideIndex = currentSlideIndexRef.current + + if (story && slide) { + const storyIdToUse = extractNumericId(story.id, story.ids) + const slideIdToUse = extractSlideIdForTracking(slide.id, slideIndex) + sdk.trackStoryClick(storyIdToUse, slideIdToUse, code) + } + } + + // Track product view if element is a product + if (element.type === 'product' && sdk && code) { + const productData = element.item || element.product + if (productData && productData.id) { + sdk.track('view', { + id: productData.id, + recommended_by: 'stories', + recommended_code: code, + }).catch((err) => { + if (__DEV__) { + console.warn('Failed to track product view from slide:', err) + } + }) + } + } + + // Handle products carousel + const productsArray = element.products || element.items || [] + if (element.type === 'products' && productsArray.length > 0) { + setCarouselProducts(productsArray) + + // Get hide label from slide settings (priority) or element labels (fallback) + // Support both snake_case (from server) and camelCase formats + // Priority: slide.settings?.labels?.hideCarousel/hide_carousel -> element.labels?.hideCarousel/hide_carousel -> 'Скрыть' + const slide = currentSlideRef.current || currentSlide + const hideLabel = slide?.settings?.labels?.hideCarousel + || slide?.settings?.labels?.hide_carousel + || element.labels?.hideCarousel + || element.labels?.hide_carousel + || 'Скрыть' + setCarouselHideLabel(hideLabel) + setCarouselVisible(true) + return + } + + // Handle deeplinks with platform detection + let urlToOpen = null + if (Platform.OS === 'ios') { + urlToOpen = element.deeplinkIos || element.linkIos || element.link + } else if (Platform.OS === 'android') { + urlToOpen = element.deeplinkAndroid || element.linkAndroid || element.link + } else { + urlToOpen = element.link + } + + // Use ref for mediaLoaded to avoid stale closure + const mediaLoadedValue = mediaLoadedRef.current + + if (urlToOpen) { + Linking.openURL(urlToOpen).catch((err) => { + if (__DEV__) { + console.warn('Failed to open URL:', err) + } + }) + + // Resume timer after a short delay - store timeout ID for cleanup + const timeoutId = setTimeout(() => { + setIsPaused(false) + if (mediaLoadedValue) { + startProgressTimerRef.current?.() + } + }, 500) + + // Store timeout ID for potential cleanup + elementPressTimeoutRef.current = timeoutId + } else { + const timeoutId = setTimeout(() => { + setIsPaused(false) + if (mediaLoadedValue) { + startProgressTimerRef.current?.() + } + }, 100) + elementPressTimeoutRef.current = timeoutId + } + + onElementPress?.(element) + }, [sdk, code, onElementPress]) + + const handleCarouselClose = useCallback(() => { + setCarouselVisible(false) + setCarouselProducts([]) + // Resume timer after carousel closes + setTimeout(() => { + setIsPaused(false) + if (mediaLoadedRef.current) { + startProgressTimerRef.current?.() + } + }, 400) + }, []) + + const handleCarouselProductPress = useCallback((product) => { + // Track product view from carousel + if (sdk && product.id && code) { + sdk.track('view', { + id: product.id, + recommended_by: 'stories', + recommended_code: code, + }).catch((err) => { + if (__DEV__) { + console.warn('Failed to track product view from carousel:', err) + } + }) + } + + // Determine which URL to use based on platform + let urlToOpen = null + + if (Platform.OS === 'ios') { + urlToOpen = product.deeplinkIos || product.url + } else if (Platform.OS === 'android') { + urlToOpen = product.deeplinkAndroid || product.url + } else { + // Fallback for other platforms + urlToOpen = product.url + } + + if (urlToOpen) { + Linking.openURL(urlToOpen).catch((err) => { + if (__DEV__) { + console.warn('Failed to open product URL:', err) + } + }) + } + + // Close carousel and resume timer + handleCarouselClose() + }, [handleCarouselClose, sdk, code]) + + const handleLongPressStart = useCallback(() => { + isLongPressingRef.current = true + isPausedRef.current = true + + // Save current progress step BEFORE clearing timer + const slide = currentSlideRef.current + if (slide && progressTimerRef.current) { + const duration = getDuration(slide) + const progressInterval = 50 + const totalSteps = duration / progressInterval + // Calculate current step from current progress (0-1) + const currentStep = Math.floor(progressRef.current * totalSteps) + pausedProgressStepRef.current = currentStep + } + + setIsPaused(true) + clearTimers() + }, [clearTimers]) + + const handleLongPressEnd = useCallback(() => { + isLongPressingRef.current = false + isPausedRef.current = false + setIsPaused(false) + // Timer will restart automatically via useEffect when isPaused becomes false + }, []) + + const handleSwipeDown = useCallback((gestureState) => { + if (isClosingRef.current) return + + const { dy } = gestureState + const swipeThreshold = screenHeight * 0.2 // 20% of screen height + + if (dy > swipeThreshold) { + // Swipe down enough to close + isClosingRef.current = true + clearTimers() + + Animated.parallel([ + Animated.timing(translateY, { + toValue: screenHeight, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(backdropOpacity, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + ]).start(() => { + onClose?.() + }) + } else { + // Spring back if not enough swipe + Animated.parallel([ + Animated.spring(translateY, { + toValue: 0, + useNativeDriver: true, + tension: 65, + friction: 11, + }), + Animated.spring(opacity, { + toValue: 1, + useNativeDriver: true, + tension: 65, + friction: 11, + }), + Animated.spring(backdropOpacity, { + toValue: 1, + useNativeDriver: true, + tension: 65, + friction: 11, + }), + ]).start() + } + }, [onClose, clearTimers]) + + const panResponder = useMemo(() => PanResponder.create({ + onStartShouldSetPanResponder: () => false, // Don't capture on start + onMoveShouldSetPanResponder: (evt, gestureState) => { + // Only respond to vertical swipes down (dy > 0 and vertical movement is dominant) + const isVerticalSwipe = Math.abs(gestureState.dy) > Math.abs(gestureState.dx) + const isSwipeDown = gestureState.dy > 10 + return isVerticalSwipe && isSwipeDown + }, + onPanResponderGrant: (evt) => { + // Pause timer when starting to drag + setIsPaused(true) + clearTimers() + }, + onPanResponderMove: (evt, gestureState) => { + // Handle vertical swipe down + if (gestureState.dy > 0 && !isClosingRef.current) { + const dragDistance = Math.min(gestureState.dy, screenHeight) + const progress = dragDistance / screenHeight + + translateY.setValue(dragDistance) + opacity.setValue(1 - progress * 0.5) // Fade out as you drag down + // Reduce backdrop opacity to reveal host screen content + backdropOpacity.setValue(1 - progress) + } + }, + onPanResponderRelease: (evt, gestureState) => { + handleSwipeDown(gestureState) + // Resume timer if not closing + if (!isClosingRef.current) { + setIsPaused(false) + } + }, + onPanResponderTerminate: (evt, gestureState) => { + // Spring back if gesture is cancelled + Animated.parallel([ + Animated.spring(translateY, { + toValue: 0, + useNativeDriver: true, + tension: 65, + friction: 11, + }), + Animated.spring(opacity, { + toValue: 1, + useNativeDriver: true, + tension: 65, + friction: 11, + }), + Animated.spring(backdropOpacity, { + toValue: 1, + useNativeDriver: true, + tension: 65, + friction: 11, + }), + ]).start(() => { + setIsPaused(false) + }) + }, + }), [handleSwipeDown, clearTimers]) + + // Pan responder for center zone - handles only long press (pause) + // Swipe down is handled by the main panResponder on storyViewer + const centerPanResponder = useMemo(() => PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: (evt, gestureState) => { + // Only respond to minimal movement (long press), not swipes + const isMinimalMovement = Math.abs(gestureState.dx) < 10 && Math.abs(gestureState.dy) < 10 + return isMinimalMovement + }, + onPanResponderGrant: (evt) => { + // Start long press timer + isLongPressingRef.current = false + longPressTimerRef.current = setTimeout(() => { + handleLongPressStartRef.current?.() + }, 200) // 200ms delay for long press + }, + onPanResponderMove: (evt, gestureState) => { + // If movement is significant, cancel long press + if (Math.abs(gestureState.dx) > 10 || Math.abs(gestureState.dy) > 10) { + // Cancel long press timer + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + } + }, + onPanResponderRelease: (evt, gestureState) => { + // Cancel long press timer + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + + // If long press was active, end it + if (isLongPressingRef.current) { + handleLongPressEndRef.current?.() + } + }, + onPanResponderTerminate: (evt, gestureState) => { + // Cancel long press timer + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + + // If long press was active, end it + if (isLongPressingRef.current) { + handleLongPressEndRef.current?.() + } + }, + }), []) + + // Store onClose in ref to avoid dependency + const onCloseRef = useRef(onClose) + useEffect(() => { + onCloseRef.current = onClose + }, [onClose]) + + // Memoize tap zone styles to avoid recreating objects + const tapZoneBottomStyle = useMemo(() => ({ bottom: 120 }), []) + const hitSlop = useMemo(() => ({ top: 0, bottom: 0, left: 0, right: 0 }), []) + + // Calculate progress bar values directly (no memoization to avoid cache issues) + const progressBgColor = currentStory ? getColorFromSettings(settings, 'backgroundProgress', DEFAULT_COLORS.backgroundProgress) : DEFAULT_COLORS.backgroundProgress + const closeButtonColor = currentStory ? getColorFromSettings(settings, 'closeColor', DEFAULT_COLORS.closeButton) : DEFAULT_COLORS.closeButton + + if (!visible || !stories || stories.length === 0) { + return null + } + + return ( + + {/* Backdrop that reveals host screen when swiping down */} + + + + {currentStory && ( + + + onCloseRef.current?.()}> + × + + + )} + + + {currentSlide && ( + <> + {/* For video slides, always show immediately (streaming) */} + {/* For image slides, show loading indicator if not preloaded */} + {!mediaPreloaded && currentSlide.type !== 'video' && ( + + + + )} + {(mediaPreloaded || currentSlide.type === 'video') && ( + + )} + + )} + + + {/* Tap zones for navigation - placed outside storyViewerContainer to be on top */} + {/* Exclude bottom area (120px) where buttons are located */} + + {/* Center zone for long press (pause) and swipe down (close) */} + + + + + {/* Products Carousel */} + + + ) +} diff --git a/components/Stories/styles.js b/components/Stories/styles.js new file mode 100644 index 0000000..9d455cc --- /dev/null +++ b/components/Stories/styles.js @@ -0,0 +1,895 @@ +import { StyleSheet, Dimensions } from 'react-native' + +const { width: screenWidth, height: screenHeight } = Dimensions.get('window') + +// Default configuration values +export const DEFAULT_CONFIG = { + iconSize: 60, + iconMargin: 8, + labelWidth: 80, + fontSize: 12, + avatarSize: 20, + storyHeight: 120, + progressBarHeight: 3, + progressBarMargin: 2, + closeButtonSize: 30, + elementMargin: 16, +} + +// Default colors +export const DEFAULT_COLORS = { + text: '#FFFFFF', + background: '#000000', + borderViewed: 'rgba(255, 107, 107, 0.3)', // Same color as borderNotViewed but with transparency + borderNotViewed: '#FF6B6B', + backgroundPin: '#FFD93D', + backgroundProgress: 'rgba(255, 255, 255, 0.3)', + closeButton: '#FFFFFF', + placeholder: '#CCCCCC', + bannerPriceSectionBackground: '#FC6B3F', + bannerPriceSectionFont: '#FFFFFF', + bannerOldPriceSectionFont: 'rgba(255, 255, 255, 0.7)', + bannerPromocodeSectionBackground: '#17AADF', + bannerPromocodeSectionFont: '#FFFFFF', + bannerDiscountSectionBackground: '#FBB800', +} + +/** + * Convert hex color to RGB values + * @param {string} hex - Hex color string (e.g., '#FF0000') + * @returns {{red: number, green: number, blue: number}} RGB values (0-1) + */ +export function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + return result ? { + red: parseInt(result[1], 16) / 255, + green: parseInt(result[2], 16) / 255, + blue: parseInt(result[3], 16) / 255, + } : { red: 0, green: 0, blue: 0 } +} + +/** + * Format price with currency + * @param {number} price - Price value + * @param {string} currency - Currency code + * @returns {string} Formatted price string + */ +export function formatPrice(price, currency = '') { + if (typeof price !== 'number') return '' + + const formattedPrice = price.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }) + + return currency ? `${formattedPrice} ${currency}` : formattedPrice +} + +/** + * Get slide display duration + * Match iOS SDK: duration is parsed for all slide types (Int in seconds, default 10) + * @param {Object} slide - Slide object + * @returns {number} Duration in milliseconds + */ +export function getDuration(slide) { + // Use duration for all slide types (not just video), match iOS SDK behavior + if (slide.duration !== undefined && slide.duration !== null) { + // Convert from seconds to milliseconds (iOS SDK stores duration in seconds) + const durationSeconds = typeof slide.duration === 'number' ? slide.duration : parseInt(slide.duration, 10) + if (!isNaN(durationSeconds) && durationSeconds > 0) { + return durationSeconds * 1000 + } + } + // Default 10 seconds (like iOS SDK), not 8 seconds + return 10000 +} + +/** + * Preload media URL + * @param {string} url - Media URL + * @returns {Promise} + */ +export function preloadMedia(url) { + return new Promise((resolve, reject) => { + if (!url) { + resolve() + return + } + + // For images, we can use Image.prefetch + if (url.match(/\.(jpg|jpeg|png|gif|webp)$/i)) { + const { Image } = require('react-native') + Image.prefetch(url) + .then(() => resolve()) + .catch(() => resolve()) // Don't fail on preload errors + } else { + // For videos, just resolve (preloading handled by video component) + resolve() + } + }) +} + +/** + * Get element positioning based on yOffset + * @param {number} yOffset - Vertical offset percentage + * @returns {Object} Position styles + */ +export function getElementPosition(yOffset) { + if (typeof yOffset !== 'number') { + return { top: '50%', transform: [{ translateY: -50 }] } + } + + const percentage = Math.max(0, Math.min(100, yOffset)) + return { + top: `${percentage}%`, + transform: [{ translateY: -50 }], + } +} + +/** + * Get text alignment style + * @param {string} alignment - Text alignment ('left', 'center', 'right') + * @returns {string} React Native text alignment + */ +export function getTextAlignment(alignment) { + switch (alignment) { + case 'left': + return 'left' + case 'right': + return 'right' + case 'center': + default: + return 'center' + } +} + +// Shared styles +export const styles = StyleSheet.create({ + // Stories List Styles + storiesContainer: { + height: DEFAULT_CONFIG.storyHeight, + backgroundColor: 'transparent', + justifyContent: 'center', + alignItems: 'center', + }, + storiesList: { + flexGrow: 0, + justifyContent: 'flex-start', // Выравнивание по верху для горизонтального списка + alignItems: 'flex-start', // Выравнивание элементов по верху + // Can be overridden via contentContainerStyle prop + }, + storyItem: { + flexDirection: 'column', + alignItems: 'center', // Центрирует все элементы по горизонтали + marginHorizontal: DEFAULT_CONFIG.iconMargin, + width: Math.max(DEFAULT_CONFIG.iconSize, DEFAULT_CONFIG.labelWidth), // Фиксированная ширина элемента (не меньше iconSize) + // Кружки будут выровнены по верху через фиксированную структуру + }, + storyCircle: { + width: DEFAULT_CONFIG.iconSize, + height: DEFAULT_CONFIG.iconSize, + borderRadius: DEFAULT_CONFIG.iconSize / 2, + borderWidth: 2, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 4, + // Кружок всегда в начале колонки, выравнивание по верху + }, + storyCircleViewed: { + borderColor: DEFAULT_COLORS.borderViewed, + }, + storyCircleNotViewed: { + borderColor: DEFAULT_COLORS.borderNotViewed, + }, + storyAvatar: { + width: DEFAULT_CONFIG.iconSize - 8, + height: DEFAULT_CONFIG.iconSize - 8, + borderRadius: (DEFAULT_CONFIG.iconSize - 8) / 2, + }, + pinIndicator: { + position: 'absolute', + height: 32, + minWidth: 32, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 8, + }, + pinSymbol: { + fontSize: 16, + textAlign: 'center', + }, + storyName: { + fontSize: DEFAULT_CONFIG.fontSize, + color: DEFAULT_COLORS.text, + textAlign: 'center', + width: DEFAULT_CONFIG.labelWidth, // Фиксированная ширина для центрирования + }, + storyPlaceholder: { + backgroundColor: DEFAULT_COLORS.placeholder, + opacity: 0.3, + }, + + // Product slide title (for slides with a single product) + productSlideTitleContainer: { + position: 'absolute', + top: 106, // Below progress bar + close button + left: 16, + right: 52, // Avoid overlap with close button + zIndex: 12, // Above card, below buttons/progress + }, + productSlideTitle: { + color: '#FFFFFF', + fontSize: 20, + fontWeight: '700', + textAlign: 'left', + lineHeight: 24, + }, + + // Price banner (plain, no discount) + priceBannerPlain: { + backgroundColor: '#FFFFFF', + borderRadius: 6, + justifyContent: 'center', + alignItems: 'flex-start', + paddingLeft: 16, + paddingRight: 16, + flex: 1, + }, + priceBannerPlainText: { + fontSize: 26, + fontWeight: '900', + color: '#000000', + }, + + // Story Viewer Styles + backdrop: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.9)', // Dark backdrop that fades to reveal host screen + }, + storyViewer: { + flex: 1, + backgroundColor: 'transparent', // Transparent to show host screen when swiping + }, + storyViewerContainer: { + flex: 1, + position: 'relative', + }, + progressContainer: { + position: 'absolute', + top: 65, + left: 16, + right: 16, + zIndex: 10, + flexDirection: 'row', + alignItems: 'center', + }, + progressBarsWrapper: { + flex: 1, + flexDirection: 'row', + marginRight: 12, + }, + progressBar: { + flex: 1, + height: DEFAULT_CONFIG.progressBarHeight, + backgroundColor: DEFAULT_COLORS.backgroundProgress, + marginHorizontal: DEFAULT_CONFIG.progressBarMargin, + borderRadius: DEFAULT_CONFIG.progressBarHeight / 2, + overflow: 'hidden', + position: 'relative', + }, + progressFill: { + position: 'absolute', + top: 0, + left: 0, + height: '100%', + backgroundColor: DEFAULT_COLORS.text, + borderRadius: DEFAULT_CONFIG.progressBarHeight / 2, + alignSelf: 'flex-start', + }, + closeButton: { + width: DEFAULT_CONFIG.closeButtonSize, + height: DEFAULT_CONFIG.closeButtonSize, + borderRadius: DEFAULT_CONFIG.closeButtonSize / 2, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + closeButtonText: { + color: DEFAULT_COLORS.closeButton, + fontSize: 18, + fontWeight: 'bold', + }, + volumeButton: { + position: 'absolute', + top: 90, // Moved down to avoid overlapping with timeline (timeline is ~0-50px from top) + left: 16, + width: DEFAULT_CONFIG.closeButtonSize, + height: DEFAULT_CONFIG.closeButtonSize, + borderRadius: DEFAULT_CONFIG.closeButtonSize / 2, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 11, + }, + volumeButtonIcon: { + fontSize: 18, + color: DEFAULT_COLORS.closeButton, + textAlign: 'center', + lineHeight: 18, + }, + + // Story Slide Styles + slideContainer: { + flex: 1, + position: 'relative', + }, + slideImage: { + width: '100%', + height: '100%', + // resizeMode will be set in component to 'contain' + }, + slideVideoContainer: { + width: '100%', + height: '100%', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + overflow: 'hidden', + backgroundColor: 'transparent', + }, + slideVideo: { + width: '100%', + height: '100%', + // Prevent video from stretching before it's ready + backgroundColor: 'transparent', + }, + hiddenMedia: { + opacity: 0, + // Keep dimensions for proper loading, just make invisible + // Image/Video will still load but won't be visible + }, + hiddenMediaContainer: { + opacity: 0, + width: '100%', + height: '100%', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + slideBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + + // Story Elements Styles + elementsContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 5, + }, + elementButton: { + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + alignItems: 'center', + justifyContent: 'center', + marginHorizontal: 16, // Add horizontal margin from screen edges + minHeight: 44, // Minimum touch target size + }, + // Fixed positioning styles for buttons (always at bottom) + elementButtonFixed: { + position: 'absolute', + bottom: 18, // Default bottom position + left: 16, + right: 16, + height: 56, + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + // backgroundColor will come from element.background (fallback: white) + alignItems: 'center', + justifyContent: 'center', + zIndex: 20, // Above promocode banner and other elements + elevation: 20, // For Android + }, + elementProductsButtonFixed: { + position: 'absolute', + bottom: 89, // Above main button when both exist + left: 66, + right: 66, + height: 36, + paddingHorizontal: 20, + paddingVertical: 8, + borderRadius: 18, // Rounded button + // backgroundColor will come from element.background (fallback: white) + alignItems: 'center', + justifyContent: 'center', + zIndex: 20, // Above promocode banner and other elements + elevation: 20, // For Android + }, + elementProductsButtonFixedSingle: { + position: 'absolute', + bottom: 28, // When no main button + left: 66, + right: 66, + height: 36, + paddingHorizontal: 20, + paddingVertical: 8, + borderRadius: 18, // Rounded button + // backgroundColor will come from element.background (fallback: white) + alignItems: 'center', + justifyContent: 'center', + zIndex: 20, // Above promocode banner and other elements + elevation: 20, // For Android + }, + elementButtonText: { + color: DEFAULT_COLORS.text, + fontSize: 16, + fontWeight: 'bold', + }, + elementText: { + color: DEFAULT_COLORS.text, + fontSize: 16, + textAlign: 'center', + paddingHorizontal: DEFAULT_CONFIG.elementMargin, + }, + elementProduct: { + backgroundColor: 'rgba(255, 255, 255, 0.9)', + borderRadius: 8, + padding: 12, + marginHorizontal: DEFAULT_CONFIG.elementMargin, + alignItems: 'center', + }, + elementProductImage: { + width: 80, + height: 80, + borderRadius: 4, + marginBottom: 8, + }, + elementProductName: { + fontSize: 14, + fontWeight: 'bold', + textAlign: 'center', + marginBottom: 4, + color: '#000000', + }, + elementProductPrice: { + fontSize: 16, + fontWeight: 'bold', + color: '#FF6B6B', + }, + + // Product card on slide (for products element) + elementProductCard: { + backgroundColor: '#FFFFFF', + borderRadius: 0, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + elementProductCardImage: { + width: '100%', + height: 300, + backgroundColor: '#F5F5F5', + }, + elementProductCardContent: { + padding: 16, + backgroundColor: '#FFFFFF', + }, + elementProductCardName: { + fontSize: 16, + fontWeight: 'normal', + textAlign: 'left', + marginBottom: 12, + color: '#000000', + }, + elementProductCardPriceRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + elementProductCardOldPrice: { + fontSize: 14, + fontWeight: 'normal', + color: '#999999', + textDecorationLine: 'line-through', + marginRight: 10, + }, + elementProductCardDiscountBadge: { + backgroundColor: '#FF6B6B', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + height: 20, + justifyContent: 'center', + }, + elementProductCardDiscount: { + fontSize: 12, + fontWeight: 'bold', + color: '#FFFFFF', + }, + elementProductCardPrice: { + fontSize: 18, + fontWeight: 'bold', + color: '#000000', + }, + + // Promocode banner (like iOS) + promocodeBanner: { + position: 'absolute', + bottom: 90, // Above buttons + left: 16, + right: 16, + height: 68, + borderRadius: 6, + flexDirection: 'row', + overflow: 'hidden', + zIndex: 9, // Below buttons (buttons have zIndex: 20) + elevation: 9, // For Android + }, + promocodeBannerPriceSection: { + backgroundColor: '#FC6B3F', // Orange like iOS + flex: 0.8, // 80% width + paddingLeft: 16, + paddingRight: 8, + justifyContent: 'center', + alignItems: 'flex-start', + }, + promocodeBannerOldPrice: { + fontSize: 16, + fontWeight: '800', + color: 'rgba(255, 255, 255, 0.7)', + textDecorationLine: 'line-through', + marginBottom: 2, + }, + promocodeBannerPrice: { + fontSize: 26, + fontWeight: '900', + color: '#FFFFFF', + }, + promocodeBannerPromoSection: { + backgroundColor: '#17AADF', // Blue like iOS + flex: 0.25, // 25% width + paddingLeft: 0, + paddingRight: 0, + justifyContent: 'center', + alignItems: 'center', + }, + promocodeBannerDiscountSection: { + backgroundColor: '#FBB800', // Yellow like iOS when no promocode + }, + promocodeBannerTitle: { + fontSize: 13, + fontWeight: 'bold', + color: '#FFFFFF', + marginBottom: 2, + }, + promocodeBannerCode: { + fontSize: 25, + fontWeight: '800', + color: '#FFFFFF', + }, + promocodeBannerDiscount: { + fontSize: 27, + fontWeight: '800', + color: '#000000', + textAlign: 'center', + }, + + // Tap Zones + tapZone: { + position: 'absolute', + top: 0, + bottom: 0, + zIndex: 5, // Higher than StorySlide elements but below close button and progress bars + // Note: pointerEvents handled at component level to allow buttons to be clickable + }, + tapZoneLeft: { + left: 0, + width: '33%', + }, + tapZoneCenter: { + left: '33%', + width: '34%', + }, + tapZoneRight: { + right: 0, + width: '33%', + }, + + // Loading States + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: DEFAULT_COLORS.background, + }, + loadingText: { + color: DEFAULT_COLORS.text, + fontSize: 16, + marginTop: 16, + }, + mediaLoadingContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, // Higher z-index to ensure loader is on top + }, + mediaLoadingBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.8)', // More opaque background to hide media behind + }, + + // Products Carousel Styles + carouselBackdrop: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + justifyContent: 'flex-end', + }, + carouselContainer: { + backgroundColor: '#F0F0F0', // Light gray background like iOS + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + overflow: 'hidden', + }, + carouselScrollView: { + flex: 1, + }, + carouselScrollContent: { + paddingTop: 20, + alignItems: 'flex-start', + }, + carouselProductCard: { + backgroundColor: '#FFFFFF', + borderRadius: 0, + alignItems: 'flex-start', + height: 330, // Approximate height based on iOS (73% of carousel height ~450) + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + carouselProductImage: { + width: '100%', + height: 165, // Half of card height (330 / 2) + marginTop: 20, + marginBottom: 0, + }, + carouselProductName: { + fontSize: 14, + fontWeight: 'normal', + textAlign: 'left', + marginLeft: 20, + marginRight: 8, + marginTop: 12, + marginBottom: 14, + color: '#000000', + minHeight: 36, + }, + carouselPriceRow: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 20, + marginBottom: 8, + }, + carouselProductPrice: { + fontSize: 16, + fontWeight: 'bold', + color: '#000000', + marginLeft: 20, + marginTop: 0, + }, + carouselProductOldPrice: { + fontSize: 14, + textDecorationLine: 'line-through', + color: '#999999', + marginRight: 10, + }, + carouselProductDiscountBadge: { + backgroundColor: '#FF6B6B', + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + height: 20, + justifyContent: 'center', + alignItems: 'center', + }, + carouselProductDiscount: { + fontSize: 12, + fontWeight: 'bold', + color: '#FFFFFF', + }, + carouselHideButton: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + paddingVertical: 16, + paddingHorizontal: 24, + alignItems: 'center', + backgroundColor: '#F0F0F0', + borderTopWidth: 1, + borderTopColor: '#E0E0E0', + }, + carouselHideButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#333333', + }, +}) + +/** + * Converts a hex color string to an RGBA object. + * @param {string} hex - The hex color string (e.g., "#RRGGBB" or "RRGGBB"). + * @param {number} [alpha=1] - The alpha value (0-1). + * @returns {{r: number, g: number, b: number, a: number}} + */ +export function hexToRgba(hex, alpha = 1) { + let c + if (/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) { + c = hex.substring(1).split('') + if (c.length === 3) { + c = [c[0], c[0], c[1], c[1], c[2], c[2]] + } + c = '0x' + c.join('') + return { + r: (c >> 16) & 255, + g: (c >> 8) & 255, + b: c & 255, + a: alpha, + } + } + // Fallback for invalid hex or other color formats + return { r: 0, g: 0, b: 0, a: alpha } +} + +/** + * Extracts numeric ID for API tracking from story/slide ID. + * Handles both numeric IDs and string IDs with numeric suffixes. + * @param {string|number} id - The ID to extract numeric value from + * @param {string|number} [ids] - Optional numeric IDs field + * @returns {number} - Numeric ID for API calls + */ +export function extractNumericId(id, ids) { + // Use numeric ids field if available and it's a positive number + if (ids !== undefined && ids !== null) { + const numericIds = Number(ids) + // Only use ids if it's a valid positive number + if (!isNaN(numericIds) && numericIds > 0) { + return numericIds + } + } + + // If id is already a number, return it (if positive) + if (typeof id === 'number') { + if (id > 0) { + return id + } + } + + // If id is a string, try to extract numeric part + if (typeof id === 'string') { + // Handle formats like "66_3" -> extract "3" + const parts = id.split('_') + if (parts.length > 1) { + const numericPart = parseInt(parts[parts.length - 1], 10) + if (!isNaN(numericPart) && numericPart > 0) { + return numericPart + } + } + + // Try to parse the entire string as a number + const numericId = parseInt(id, 10) + if (!isNaN(numericId) && numericId > 0) { + return numericId + } + } + + // Fallback: return 0 if we can't extract a valid positive number + return 0 +} + +/** + * Extracts slide ID for tracking based on slide ID type. + * If slide ID is a string, returns slideIndex + 1. + * If slide ID is a number, returns the ID as is. + * @param {string|number} slideId - The slide ID + * @param {number} slideIndex - The index of the slide in the slides array + * @returns {number} - Slide ID for API tracking (slideIndex + 1 for string IDs) + */ +export function extractSlideIdForTracking(slideId, slideIndex) { + // If slideId is a string, use slideIndex + 1 + if (typeof slideId === 'string') { + return slideIndex + 1 + } + + // If slideId is a number, use it as is + if (typeof slideId === 'number') { + return slideId + } + + // Fallback: use slideIndex + 1 + return slideIndex + 1 +} + +/** + * Safely extracts a color from settings object with fallback. + * @param {Object|null} settings - Settings object from API + * @param {string} key - Color key to extract (e.g., 'borderViewed', 'closeColor') + * @param {string} fallback - Fallback color to use if setting is missing + * @returns {string} - Color value (hex or rgba string) + */ +function toSnakeCaseKey(key) { + return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) +} + +export function getColorFromSettings(settings, key, fallback) { + if (!settings || typeof settings !== 'object') { + return fallback + } + + const color = settings[key] + const snakeColor = settings[toSnakeCaseKey(key)] + + // Check if color exists and is a non-empty string + if (color && typeof color === 'string' && color.trim() !== '') { + return color + } + + if (snakeColor && typeof snakeColor === 'string' && snakeColor.trim() !== '') { + return snakeColor + } + + return fallback +} + +/** + * Safely extracts a color from element object with fallback. + * Matches iOS SDK behavior for button and text colors. + * @param {Object|null} element - Element object from API + * @param {string} key - Color key to extract (e.g., 'color', 'background', 'textColor') + * @param {string} fallback - Fallback color to use if element color is missing + * @returns {string} - Color value (hex or rgba string) + */ +export function getColorFromElement(element, key, fallback) { + if (!element || typeof element !== 'object') { + return fallback + } + + const color = element[key] + + // Check if color exists and is a non-empty string + if (color && typeof color === 'string' && color.trim() !== '') { + return color + } + + return fallback +} diff --git a/lib/client.js b/lib/client.js index 516a5b9..94d32b3 100644 --- a/lib/client.js +++ b/lib/client.js @@ -161,14 +161,22 @@ export async function updSeance(shop_id, did = '', seance = '') { /** * Generates a session identifier (SID). + * React Native compatible version (no Buffer dependency). * * @returns {string} The generated SID. */ export function generateSid() { - const sid = Buffer.from(String(Math.random())) - .toString("base64") - .replace(/=/g, '') - .substring(0, 10); + // Generate random alphanumeric string (React Native compatible) + // Using Math.random() and base36 encoding which is available in React Native + const randomPart1 = Math.random().toString(36).substring(2, 7) + const randomPart2 = Math.random().toString(36).substring(2, 7) + const timestamp = Date.now().toString(36).substring(0, 3) + + // Combine and take first 10 characters + const sid = (randomPart1 + randomPart2 + timestamp) + .replace(/[^a-zA-Z0-9]/g, '') + .substring(0, 10) + .padEnd(10, Math.random().toString(36).substring(2)) return sid } @@ -185,6 +193,7 @@ export async function request(url, shop_id, options = {}) { const config = { method: options?.method || "GET", ...options, + params: options?.params || {}, }; const URL = `${SDK_API_URL}${url}`; const storageData = await getData(shop_id); diff --git a/lib/stories/cacheManager.js b/lib/stories/cacheManager.js new file mode 100644 index 0000000..47f5e9a --- /dev/null +++ b/lib/stories/cacheManager.js @@ -0,0 +1,176 @@ +import { AppState } from 'react-native' +import { cleanupOldCache as cleanupOldImageCache, getCacheSize as getImageCacheSize, clearImageCache } from './imageCache' +import { cleanupOldVideoCache, getVideoCacheSize, clearVideoCache } from './videoCache' + +// Maximum cache size in bytes (default: 100MB) +const MAX_CACHE_SIZE = 100 * 1024 * 1024 // 100MB + +// Cache cleanup age in days (default: 7 days) +const CACHE_CLEANUP_AGE_DAYS = 7 + +// AppState listener for memory warnings +let appStateSubscription = null + +/** + * Get total cache size (images + videos) + * @returns {Promise} Total cache size in bytes + */ +export async function getTotalCacheSize() { + try { + const imageCacheSize = await getImageCacheSize() + const videoCacheSize = await getVideoCacheSize() + return imageCacheSize + videoCacheSize + } catch (error) { + if (__DEV__) { + console.warn('[cacheManager] Error getting total cache size:', error) + } + return 0 + } +} + +/** + * Clean up old cache entries + * @param {number} daysOld - Remove entries older than this many days (default: 7) + * @returns {Promise<{imagesRemoved: number, videosRemoved: number}>} Number of entries removed + */ +export async function cleanupOldCache(daysOld = CACHE_CLEANUP_AGE_DAYS) { + try { + const imagesRemoved = await cleanupOldImageCache(daysOld) + const videosRemoved = await cleanupOldVideoCache(daysOld) + + if (__DEV__) { + console.log(`[cacheManager] Cleaned up old cache: ${imagesRemoved} images, ${videosRemoved} videos`) + } + + return { imagesRemoved, videosRemoved } + } catch (error) { + if (__DEV__) { + console.warn('[cacheManager] Error cleaning up old cache:', error) + } + return { imagesRemoved: 0, videosRemoved: 0 } + } +} + +/** + * Clear all cache + * @returns {Promise} + */ +export async function clearAllCache() { + try { + await clearImageCache() + await clearVideoCache() + + if (__DEV__) { + console.log('[cacheManager] All cache cleared') + } + } catch (error) { + if (__DEV__) { + console.warn('[cacheManager] Error clearing all cache:', error) + } + } +} + +/** + * Manage cache size by removing oldest entries if cache exceeds maximum size + * @param {number} maxSize - Maximum cache size in bytes (default: 100MB) + * @returns {Promise<{cleared: boolean, sizeBefore: number, sizeAfter: number}>} + */ +export async function manageCacheSize(maxSize = MAX_CACHE_SIZE) { + try { + const currentSize = await getTotalCacheSize() + + if (currentSize <= maxSize) { + return { cleared: false, sizeBefore: currentSize, sizeAfter: currentSize } + } + + if (__DEV__) { + console.log(`[cacheManager] Cache size (${(currentSize / 1024 / 1024).toFixed(2)}MB) exceeds maximum (${(maxSize / 1024 / 1024).toFixed(2)}MB), cleaning up...`) + } + + // Start with cleaning up old entries (7 days) + await cleanupOldCache(CACHE_CLEANUP_AGE_DAYS) + + // Check size again + let newSize = await getTotalCacheSize() + + // If still too large, clean up older entries (3 days) + if (newSize > maxSize) { + await cleanupOldCache(3) + newSize = await getTotalCacheSize() + } + + // If still too large, clean up very old entries (1 day) + if (newSize > maxSize) { + await cleanupOldCache(1) + newSize = await getTotalCacheSize() + } + + // If still too large, clear all cache (last resort) + if (newSize > maxSize) { + await clearAllCache() + newSize = await getTotalCacheSize() + } + + if (__DEV__) { + console.log(`[cacheManager] Cache size after cleanup: ${(newSize / 1024 / 1024).toFixed(2)}MB`) + } + + return { cleared: true, sizeBefore: currentSize, sizeAfter: newSize } + } catch (error) { + if (__DEV__) { + console.warn('[cacheManager] Error managing cache size:', error) + } + return { cleared: false, sizeBefore: 0, sizeAfter: 0 } + } +} + +/** + * Initialize cache management + * Sets up AppState listener for memory warnings and periodic cleanup + */ +export function initializeCacheManagement() { + if (appStateSubscription) { + return // Already initialized + } + + // Clean up old cache on app start + cleanupOldCache(CACHE_CLEANUP_AGE_DAYS).catch(() => { + // Silent fail + }) + + // Manage cache size on app start + manageCacheSize(MAX_CACHE_SIZE).catch(() => { + // Silent fail + }) + + // Listen for app state changes to clean up on memory warnings + const handleAppStateChange = (nextAppState) => { + if (nextAppState === 'background') { + // Clean up old cache when app goes to background + cleanupOldCache(CACHE_CLEANUP_AGE_DAYS).catch(() => { + // Silent fail + }) + + // Manage cache size when app goes to background + manageCacheSize(MAX_CACHE_SIZE).catch(() => { + // Silent fail + }) + } + } + + appStateSubscription = AppState.addEventListener('change', handleAppStateChange) +} + +/** + * Cleanup cache management + * Removes AppState listener + */ +export function cleanupCacheManagement() { + if (appStateSubscription) { + appStateSubscription.remove() + appStateSubscription = null + } +} + +// Auto-initialize cache management +initializeCacheManagement() diff --git a/lib/stories/imageCache.js b/lib/stories/imageCache.js new file mode 100644 index 0000000..c2a9502 --- /dev/null +++ b/lib/stories/imageCache.js @@ -0,0 +1,190 @@ +import { Image } from 'react-native' +import AsyncStorage from '@react-native-async-storage/async-storage' + +// In-memory cache for fast access +const memoryCache = new Map() + +// Cache metadata keys +const CACHE_METADATA_KEY = 'stories.imageCache.metadata' +const CACHE_TIMESTAMP_KEY = 'stories.imageCache.timestamps' + +/** + * Get cached image metadata from AsyncStorage + * @returns {Promise>} Map of URL -> timestamp + */ +async function getCacheMetadata() { + try { + const metadataStr = await AsyncStorage.getItem(CACHE_METADATA_KEY) + if (metadataStr) { + const metadata = JSON.parse(metadataStr) + return new Map(Object.entries(metadata)) + } + } catch (error) { + if (__DEV__) { + console.warn('[imageCache] Error getting cache metadata:', error) + } + } + return new Map() +} + +/** + * Save cache metadata to AsyncStorage + * @param {Map} metadata - Map of URL -> timestamp + */ +async function saveCacheMetadata(metadata) { + try { + const metadataObj = Object.fromEntries(metadata) + await AsyncStorage.setItem(CACHE_METADATA_KEY, JSON.stringify(metadataObj)) + } catch (error) { + if (__DEV__) { + console.warn('[imageCache] Error saving cache metadata:', error) + } + } +} + +/** + * Check if image is cached (in memory or AsyncStorage metadata) + * @param {string} url - Image URL + * @returns {Promise} True if image is cached + */ +export async function isImageCached(url) { + if (!url || typeof url !== 'string') { + return false + } + + // Check in-memory cache first + if (memoryCache.has(url)) { + return true + } + + // Check AsyncStorage metadata + const metadata = await getCacheMetadata() + return metadata.has(url) +} + +/** + * Get cached image URL (for React Native Image component) + * React Native Image.prefetch() automatically caches images, + * so we just return the original URL if cached + * @param {string} url - Image URL + * @returns {Promise} Cached image URL or null + */ +export async function getCachedImage(url) { + if (!url || typeof url !== 'string') { + return null + } + + const isCached = await isImageCached(url) + if (isCached) { + return url // React Native Image component uses cached version automatically + } + + return null +} + +/** + * Preload image and save to cache + * Uses Image.prefetch() which is non-blocking and handles caching automatically + * @param {string} url - Image URL to preload + * @returns {Promise} True if successfully cached, false otherwise + */ +export async function preloadImage(url) { + if (!url || typeof url !== 'string') { + return false + } + + // Check if already cached + const alreadyCached = await isImageCached(url) + if (alreadyCached) { + return true + } + + try { + // Use Image.prefetch() - non-blocking, native caching + await Image.prefetch(url) + + // Mark as cached in memory + memoryCache.set(url, Date.now()) + + // Update AsyncStorage metadata + const metadata = await getCacheMetadata() + metadata.set(url, Date.now()) + await saveCacheMetadata(metadata) + + return true + } catch (error) { + // Don't fail on preload errors - this is background operation + if (__DEV__) { + console.warn('[imageCache] Error preloading image:', url, error) + } + return false + } +} + +/** + * Clear image cache + * @param {string[]} [urls] - Optional array of URLs to clear. If not provided, clears all + */ +export async function clearImageCache(urls = null) { + try { + if (urls && Array.isArray(urls)) { + // Clear specific URLs + urls.forEach(url => { + memoryCache.delete(url) + }) + const metadata = await getCacheMetadata() + urls.forEach(url => { + metadata.delete(url) + }) + await saveCacheMetadata(metadata) + } else { + // Clear all + memoryCache.clear() + await AsyncStorage.removeItem(CACHE_METADATA_KEY) + await AsyncStorage.removeItem(CACHE_TIMESTAMP_KEY) + } + } catch (error) { + if (__DEV__) { + console.warn('[imageCache] Error clearing cache:', error) + } + } +} + +/** + * Get cache size (number of cached images) + * @returns {Promise} Number of cached images + */ +export async function getCacheSize() { + const metadata = await getCacheMetadata() + return metadata.size +} + +/** + * Clean up old cache entries (older than specified days) + * @param {number} daysOld - Remove entries older than this many days + * @returns {Promise} Number of entries removed + */ +export async function cleanupOldCache(daysOld = 7) { + try { + const metadata = await getCacheMetadata() + const now = Date.now() + const maxAge = daysOld * 24 * 60 * 60 * 1000 // Convert days to milliseconds + let removedCount = 0 + + for (const [url, timestamp] of metadata.entries()) { + if (now - timestamp > maxAge) { + metadata.delete(url) + memoryCache.delete(url) + removedCount++ + } + } + + await saveCacheMetadata(metadata) + return removedCount + } catch (error) { + if (__DEV__) { + console.warn('[imageCache] Error cleaning up old cache:', error) + } + return 0 + } +} diff --git a/lib/stories/slidePreloader.js b/lib/stories/slidePreloader.js new file mode 100644 index 0000000..03f2558 --- /dev/null +++ b/lib/stories/slidePreloader.js @@ -0,0 +1,576 @@ +import { AppState } from 'react-native' +import { preloadImage } from './imageCache' +import { preloadVideo, isVideoCached, getCachedVideoPath, setVideoDuration } from './videoCache' +import { isImageCached } from './imageCache' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { initializeCacheManagement, cleanupCacheManagement } from './cacheManager' + +// Priority levels +export const PRIORITY = { + HIGH: 'high', // Current slide - load immediately + MEDIUM: 'medium', // Next slide - load with minimal delay + LOW: 'low', // Other slides - load with delay +} + +// Throttling delays (in milliseconds) +const DELAYS = { + [PRIORITY.HIGH]: 0, // No delay for high priority + [PRIORITY.MEDIUM]: 100, // 100ms delay for medium priority + [PRIORITY.LOW]: 200, // 200ms delay for low priority +} + +// Cache key for slide preload status +const PRELOAD_STATUS_KEY = 'stories.slidePreload.status' + +// Queue state +let highPriorityQueue = [] +let lowPriorityQueue = [] +let isProcessing = false +let isPaused = false +let currentProcessingSlideId = null +let processingTimeout = null + +// Status tracking: pending, loading, cached, error +const slideStatus = new Map() + +// Event listeners for slide ready notifications +const slideReadyListeners = new Map() + +/** + * Get preload status from AsyncStorage + * @returns {Promise>} Map of slideId -> status + */ +async function getPreloadStatusFromStorage() { + try { + const statusStr = await AsyncStorage.getItem(PRELOAD_STATUS_KEY) + if (statusStr) { + const status = JSON.parse(statusStr) + return new Map(Object.entries(status)) + } + } catch (error) { + if (__DEV__) { + console.warn('[slidePreloader] Error getting preload status:', error) + } + } + return new Map() +} + +/** + * Save preload status to AsyncStorage + * @param {Map} status - Map of slideId -> status + */ +async function savePreloadStatusToStorage(status) { + try { + const statusObj = Object.fromEntries(status) + await AsyncStorage.setItem(PRELOAD_STATUS_KEY, JSON.stringify(statusObj)) + } catch (error) { + if (__DEV__) { + console.warn('[slidePreloader] Error saving preload status:', error) + } + } +} + +/** + * Update slide status + * @param {string} slideId - Slide ID + * @param {string} status - Status: 'pending', 'loading', 'cached', 'error' + */ +async function updateSlideStatus(slideId, status) { + slideStatus.set(slideId, status) + + // Save to AsyncStorage + const storageStatus = await getPreloadStatusFromStorage() + storageStatus.set(slideId, status) + await savePreloadStatusToStorage(storageStatus) + + // Notify listeners if cached + if (status === 'cached') { + const listeners = slideReadyListeners.get(slideId) + if (listeners) { + listeners.forEach(listener => listener()) + slideReadyListeners.delete(slideId) + } + } +} + +/** + * Check if slide media is already cached + * @param {Object} slide - Slide object + * @returns {Promise} True if slide is cached + */ +async function isSlideMediaCached(slide) { + if (!slide || !slide.id) { + return false + } + + // Check image + if (slide.type === 'image' && slide.background) { + const imageCached = await isImageCached(slide.background) + if (!imageCached) { + return false + } + } + + // Check video + if (slide.type === 'video' && slide.background) { + const videoCached = await isVideoCached(slide.id) + if (!videoCached) { + return false + } + } + + return true +} + +/** + * Preload a single slide + * @param {Object} slide - Slide object + * @param {string} priority - Priority level + * @returns {Promise} True if successfully preloaded + */ +async function preloadSlideMedia(slide, priority) { + if (!slide || !slide.id) { + return false + } + + const slideId = slide.id + + // Check if already cached + const alreadyCached = await isSlideMediaCached(slide) + if (alreadyCached) { + await updateSlideStatus(slideId, 'cached') + return true + } + + // Update status to loading + await updateSlideStatus(slideId, 'loading') + currentProcessingSlideId = slideId + + try { + // Preload image or video based on slide type + if (slide.type === 'image' && slide.background) { + const success = await preloadImage(slide.background) + if (success) { + await updateSlideStatus(slideId, 'cached') + return true + } else { + await updateSlideStatus(slideId, 'error') + return false + } + } else if (slide.type === 'video' && slide.background) { + try { + if (__DEV__) { + console.log(`[slidePreloader] Starting video preload for slide ${slideId}`) + } + const result = await preloadVideo(slideId, slide.background) + if (result && result.path) { + if (__DEV__) { + console.log(`[slidePreloader] Video preloaded successfully for slide ${slideId}: ${result.path}`) + } + // If duration is provided, save it + if (result.duration) { + await setVideoDuration(slideId, result.duration) + } + await updateSlideStatus(slideId, 'cached') + return true + } else { + if (__DEV__) { + console.warn(`[slidePreloader] Video preload failed for slide ${slideId}: no path returned`) + } + await updateSlideStatus(slideId, 'error') + return false + } + } catch (error) { + if (__DEV__) { + console.warn(`[slidePreloader] Video preload error for slide ${slideId}:`, error) + } + if (error.message === 'Video preload cancelled') { + // Don't mark as error if cancelled + await updateSlideStatus(slideId, 'pending') + return false + } + await updateSlideStatus(slideId, 'error') + return false + } + } else { + // No media to preload + await updateSlideStatus(slideId, 'cached') + return true + } + } catch (error) { + if (__DEV__) { + console.warn('[slidePreloader] Error preloading slide:', slideId, error) + } + await updateSlideStatus(slideId, 'error') + return false + } finally { + currentProcessingSlideId = null + } +} + +/** + * Process next item in queue + */ +async function processNext() { + // Don't process if paused or already processing + if (isPaused || isProcessing) { + return + } + + // Check if there's anything in queues + if (highPriorityQueue.length === 0 && lowPriorityQueue.length === 0) { + isProcessing = false + return + } + + isProcessing = true + + // Get next slide from high priority queue first, then low priority + let nextSlide = null + let nextPriority = null + + if (highPriorityQueue.length > 0) { + nextSlide = highPriorityQueue.shift() + nextPriority = PRIORITY.HIGH + } else if (lowPriorityQueue.length > 0) { + nextSlide = lowPriorityQueue.shift() + nextPriority = PRIORITY.LOW + } + + if (!nextSlide) { + isProcessing = false + return + } + + const { slide, priority } = nextSlide + const delay = DELAYS[priority] || DELAYS[PRIORITY.LOW] + + if (__DEV__) { + console.log(`[slidePreloader] Processing slide ${slide.id} (${slide.type}) with priority ${priority}, delay: ${delay}ms`) + } + + // Apply delay for low/medium priority (high priority has 0 delay) + if (delay > 0) { + await new Promise(resolve => setTimeout(resolve, delay)) + } + + // Check if paused during delay + if (isPaused) { + // Put back in queue + if (priority === PRIORITY.HIGH) { + highPriorityQueue.unshift(nextSlide) + } else { + lowPriorityQueue.unshift(nextSlide) + } + isProcessing = false + return + } + + // Preload the slide + await preloadSlideMedia(slide, priority) + + // Process next item + isProcessing = false + processNext() +} + +/** + * Add slide to preload queue + * @param {Object} slide - Slide object + * @param {string} priority - Priority level (HIGH, MEDIUM, LOW) + */ +function addToQueue(slide, priority = PRIORITY.LOW) { + if (!slide || !slide.id) { + return + } + + const slideId = slide.id + + // Check if already in queue + const inHighQueue = highPriorityQueue.some(item => item.slide.id === slideId) + const inLowQueue = lowPriorityQueue.some(item => item.slide.id === slideId) + + if (inHighQueue || inLowQueue) { + return // Already queued + } + + // Check current status + const currentStatus = slideStatus.get(slideId) + if (currentStatus === 'cached' || currentStatus === 'loading') { + return // Already cached or loading + } + + const queueItem = { slide, priority } + + if (priority === PRIORITY.HIGH) { + highPriorityQueue.push(queueItem) + } else { + lowPriorityQueue.push(queueItem) + } + + // Start processing if not already processing + if (!isProcessing) { + processNext() + } +} + +/** + * Preload a single slide with priority + * @param {Object} slide - Slide object + * @param {string} priority - Priority level (default: LOW) + * @returns {Promise} True if successfully queued + */ +export async function preloadSlide(slide, priority = PRIORITY.LOW) { + if (!slide || !slide.id) { + return false + } + + const slideId = slide.id + + // Check if already cached + const alreadyCached = await isSlideMediaCached(slide) + if (alreadyCached) { + await updateSlideStatus(slideId, 'cached') + return true + } + + // Add to queue + addToQueue(slide, priority) + return true +} + +/** + * Preload all slides from stories + * @param {Array} stories - Array of story objects + * @param {Object} options - Options + * @param {number} options.currentStoryIndex - Current story index (for prioritization) + * @param {number} options.currentSlideIndex - Current slide index (for prioritization) + * @param {boolean} options.preloadAll - If false, only preload visible/next slides (default: true) + */ +export async function preloadSlides(stories, options = {}) { + if (!stories || !Array.isArray(stories) || stories.length === 0) { + return + } + + const { + currentStoryIndex = 0, + currentSlideIndex = 0, + preloadAll = true, + } = options + + // Initialize status from storage + const storageStatus = await getPreloadStatusFromStorage() + storageStatus.forEach((status, slideId) => { + slideStatus.set(slideId, status) + }) + + // Collect all slides with their priorities + const slidesToPreload = [] + + stories.forEach((story, storyIdx) => { + if (!story.slides || !Array.isArray(story.slides)) { + return + } + + story.slides.forEach((slide, slideIdx) => { + if (!slide || !slide.id) { + return + } + + // Determine priority + let priority = PRIORITY.LOW + + if (storyIdx === currentStoryIndex) { + if (slideIdx === currentSlideIndex) { + priority = PRIORITY.HIGH // Current slide + } else if (slideIdx === currentSlideIndex + 1) { + priority = PRIORITY.MEDIUM // Next slide + } else if (preloadAll) { + priority = PRIORITY.LOW // Other slides in current story + } else { + return // Skip if not preloading all + } + } else if (storyIdx === currentStoryIndex + 1 && preloadAll) { + priority = PRIORITY.MEDIUM // Next story + } else if (preloadAll) { + priority = PRIORITY.LOW // Other stories + } else { + return // Skip if not preloading all + } + + slidesToPreload.push({ slide, priority }) + }) + }) + + // Add slides to queue with delay to avoid blocking + // Use setTimeout to ensure this runs in background + setTimeout(() => { + slidesToPreload.forEach(({ slide, priority }) => { + addToQueue(slide, priority) + }) + }, 0) +} + +/** + * Check if slide is preloaded + * @param {string} slideId - Slide ID + * @returns {Promise} True if slide is preloaded + */ +export async function isSlidePreloaded(slideId) { + if (!slideId) { + return false + } + + // Check in-memory status first + const status = slideStatus.get(slideId) + if (status === 'cached') { + return true + } + + // Check storage status + const storageStatus = await getPreloadStatusFromStorage() + return storageStatus.get(slideId) === 'cached' +} + +/** + * Get preload status for a slide + * @param {string} slideId - Slide ID + * @returns {Promise} Status: 'pending', 'loading', 'cached', 'error', or null + */ +export async function getPreloadStatus(slideId) { + if (!slideId) { + return null + } + + // Check in-memory status first + const status = slideStatus.get(slideId) + if (status) { + return status + } + + // Check storage status + const storageStatus = await getPreloadStatusFromStorage() + return storageStatus.get(slideId) || 'pending' +} + +/** + * Cancel all preloads + */ +export function cancelAllPreloads() { + // Clear queues + highPriorityQueue = [] + lowPriorityQueue = [] + + // Cancel current processing + if (currentProcessingSlideId) { + // Note: We can't cancel Image.prefetch(), but we can cancel video downloads + // Video cancellation is handled in videoCache + currentProcessingSlideId = null + } + + // Clear timeout + if (processingTimeout) { + clearTimeout(processingTimeout) + processingTimeout = null + } + + isProcessing = false +} + +/** + * Pause preloading + */ +export function pausePreloading() { + isPaused = true +} + +/** + * Resume preloading + */ +export function resumePreloading() { + if (isPaused) { + isPaused = false + // Resume processing if there are items in queue + if (!isProcessing && (highPriorityQueue.length > 0 || lowPriorityQueue.length > 0)) { + processNext() + } + } +} + +/** + * Add listener for slide ready notification + * @param {string} slideId - Slide ID + * @param {Function} callback - Callback function + * @returns {Function} Unsubscribe function + */ +export function onSlideReady(slideId, callback) { + if (!slideId || typeof callback !== 'function') { + return () => {} + } + + // Check if already cached + isSlidePreloaded(slideId).then(cached => { + if (cached) { + callback() + return + } + }) + + // Add listener + if (!slideReadyListeners.has(slideId)) { + slideReadyListeners.set(slideId, []) + } + slideReadyListeners.get(slideId).push(callback) + + // Return unsubscribe function + return () => { + const listeners = slideReadyListeners.get(slideId) + if (listeners) { + const index = listeners.indexOf(callback) + if (index > -1) { + listeners.splice(index, 1) + } + if (listeners.length === 0) { + slideReadyListeners.delete(slideId) + } + } + } +} + +// Initialize AppState listener for pause/resume +let appStateSubscription = null + +/** + * Initialize AppState integration + */ +export function initializeAppStateIntegration() { + if (appStateSubscription) { + return // Already initialized + } + + const handleAppStateChange = (nextAppState) => { + if (nextAppState === 'background' || nextAppState === 'inactive') { + pausePreloading() + } else if (nextAppState === 'active') { + resumePreloading() + } + } + + appStateSubscription = AppState.addEventListener('change', handleAppStateChange) +} + +/** + * Cleanup AppState integration + */ +export function cleanupAppStateIntegration() { + if (appStateSubscription) { + appStateSubscription.remove() + appStateSubscription = null + } +} + +// Auto-initialize AppState integration +initializeAppStateIntegration() + +// Auto-initialize cache management +initializeCacheManagement() diff --git a/lib/stories/storage.js b/lib/stories/storage.js new file mode 100644 index 0000000..15cd2b1 --- /dev/null +++ b/lib/stories/storage.js @@ -0,0 +1,187 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +/** + * Get viewed slide IDs for a specific story + * @param {string} storyId - Story identifier + * @returns {Promise} Array of viewed slide IDs + */ +export async function getViewedSlides(storyId) { + try { + const key = `viewed.slide.${storyId}` + const viewedSlides = await AsyncStorage.getItem(key) + const parsed = viewedSlides ? JSON.parse(viewedSlides) : [] + // Normalize to strings (IDs may come as numbers from API / storage) + return Array.isArray(parsed) ? parsed.map((id) => String(id)) : [] + } catch (error) { + console.warn('Error getting viewed slides:', error) + return [] + } +} + +/** + * Mark a slide as viewed for a specific story + * @param {string} storyId - Story identifier + * @param {string} slideId - Slide identifier + * @returns {Promise} + */ +export async function markSlideAsViewed(storyId, slideId) { + try { + const key = `viewed.slide.${storyId}` + const viewedSlides = await getViewedSlides(storyId) + const normalizedSlideId = String(slideId) + + if (!viewedSlides.includes(normalizedSlideId)) { + viewedSlides.push(normalizedSlideId) + await AsyncStorage.setItem(key, JSON.stringify(viewedSlides)) + } + } catch (error) { + console.warn('Error marking slide as viewed:', error) + } +} + +/** + * Get the last viewed slide ID for a specific story + * @param {string} storyId - Story identifier + * @returns {Promise} Last viewed slide ID or null + */ +export async function getLastViewedSlide(storyId) { + try { + const viewedSlides = await getViewedSlides(storyId) + return viewedSlides.length > 0 ? viewedSlides[viewedSlides.length - 1] : null + } catch (error) { + console.warn('Error getting last viewed slide:', error) + return null + } +} + +/** + * Set the last seen slide ID for a specific story + * @param {string} storyId - Story identifier + * @param {string} slideId - Slide identifier + * @returns {Promise} + */ +export async function setLastSeenSlide(storyId, slideId) { + try { + const key = `lastSeen.slide.${storyId}` + const normalizedSlideId = String(slideId) + await AsyncStorage.setItem(key, normalizedSlideId) + } catch (error) { + console.warn('Error setting last seen slide:', error) + } +} + +/** + * Get the last seen slide ID for a specific story + * @param {string} storyId - Story identifier + * @returns {Promise} Last seen slide ID or null + */ +export async function getLastSeenSlide(storyId) { + try { + const key = `lastSeen.slide.${storyId}` + const lastSeenSlide = await AsyncStorage.getItem(key) + return lastSeenSlide ? String(lastSeenSlide) : null + } catch (error) { + console.warn('Error getting last seen slide:', error) + return null + } +} + +/** + * Check if a story is fully viewed (all slides viewed) + * @param {string} storyId - Story identifier + * @param {string[]} allSlideIds - All slide IDs for the story + * @returns {Promise} True if all slides are viewed + */ +export async function isStoryFullyViewed(storyId, allSlideIds) { + try { + const viewedSlides = await getViewedSlides(storyId) + const normalizedAllSlideIds = Array.isArray(allSlideIds) ? allSlideIds.map((id) => String(id)) : [] + const isFullyViewed = normalizedAllSlideIds.every((slideId) => viewedSlides.includes(slideId)) + return isFullyViewed + } catch (error) { + console.warn('Error checking if story is fully viewed:', error) + return false + } +} + +/** + * Clear all stories cache/viewed state + * @returns {Promise} + */ +export async function clearStoriesCache() { + try { + const keys = await AsyncStorage.getAllKeys() + const storyKeys = keys.filter(key => + key.startsWith('viewed.slide.') || key.startsWith('lastSeen.slide.') + ) + await AsyncStorage.multiRemove(storyKeys) + } catch (error) { + console.warn('Error clearing stories cache:', error) + } +} + +/** + * Get the starting slide index for a story based on viewed state + * @param {string} storyId - Story identifier + * @param {string[]} allSlideIds - All slide IDs for the story + * @param {number} defaultStartPosition - Default start position from story data + * @returns {Promise} Starting slide index + */ +export async function getStartSlideIndex(storyId, allSlideIds, defaultStartPosition = 0) { + try { + // Validate inputs + if (!storyId || !allSlideIds || !Array.isArray(allSlideIds) || allSlideIds.length === 0) { + return defaultStartPosition + } + + const normalizedAllSlideIds = allSlideIds.map((id) => String(id)) + + // First, check if we have a last seen slide position + const lastSeenSlide = await getLastSeenSlide(storyId) + if (lastSeenSlide) { + const lastSeenIndex = normalizedAllSlideIds.findIndex((id) => id === lastSeenSlide) + if (lastSeenIndex !== -1) { + // If last seen slide is NOT the final slide, resume from that slide + if (lastSeenIndex < normalizedAllSlideIds.length - 1) { + return lastSeenIndex + } else { + // If last seen slide IS the final slide, start from the first slide + return 0 + } + } + } + + // Fallback to old logic if no last seen slide or it doesn't match + // If story is fully viewed, always start from the first slide + const fullyViewed = await isStoryFullyViewed(storyId, normalizedAllSlideIds) + if (fullyViewed) { + return 0 + } + + const lastViewedSlide = await getLastViewedSlide(storyId) + + if (lastViewedSlide) { + const lastViewedIndex = normalizedAllSlideIds.findIndex((id) => id === String(lastViewedSlide)) + if (lastViewedIndex !== -1) { + // If user already reached the last slide, treat story as completed and start from beginning + if (lastViewedIndex >= normalizedAllSlideIds.length - 1) { + return 0 + } + // Resume from next slide after last viewed + const nextIndex = Math.min(lastViewedIndex + 1, normalizedAllSlideIds.length - 1) + return nextIndex + } + } + + if (__DEV__) { + console.log('[storage] Using default start position:', { + storyId, + defaultStartPosition, + }) + } + return defaultStartPosition + } catch (error) { + console.warn('Error getting start slide index:', error) + return defaultStartPosition + } +} diff --git a/lib/stories/types.js b/lib/stories/types.js new file mode 100644 index 0000000..2690482 --- /dev/null +++ b/lib/stories/types.js @@ -0,0 +1,136 @@ +/** + * @typedef {Object} StoryContent + * @property {string} id - Story content identifier + * @property {StoriesSettings} settings - Story display settings + * @property {Story[]} stories - Array of stories + */ + +/** + * @typedef {Object} StoriesSettings + * @property {string} color - Text color + * @property {number} fontSize - Font size + * @property {number} avatarSize - Avatar size + * @property {string} closeColor - Close button color + * @property {string} borderViewed - Viewed story border color + * @property {string} backgroundPin - Pinned story background color + * @property {string} borderNotViewed - Unviewed story border color + * @property {string} backgroundProgress - Progress bar background color + * @property {string} pinSymbol - Pin symbol + */ + +/** + * @typedef {Object} Story + * @property {string} id - Story identifier + * @property {number} ids - Story numeric identifier + * @property {string} name - Story name/title + * @property {string} avatar - Avatar image URL + * @property {boolean} viewed - Whether story has been viewed + * @property {boolean} pinned - Whether story is pinned + * @property {number} startPosition - Starting slide position + * @property {Slide[]} slides - Array of slides + */ + +/** + * @typedef {Object} Slide + * @property {string} id - Slide identifier + * @property {number} ids - Slide numeric identifier + * @property {SlideType} type - Slide type (image/video) + * @property {number} duration - Slide duration in seconds + * @property {string} background - Background image/video URL + * @property {string} backgroundColor - Background color fallback + * @property {string} [preview] - Preview image URL for video slides + * @property {StoriesElement[]} elements - Interactive elements + */ + +/** + * @typedef {Object} StoriesElement + * @property {ElementType} type - Element type + * @property {string} [link] - Web link URL + * @property {string} [deeplinkIos] - iOS deeplink URL + * @property {string} [deeplinkAndroid] - Android deeplink URL + * @property {string} [linkIos] - iOS link URL + * @property {string} [linkAndroid] - Android link URL + * @property {string} [title] - Button title + * @property {string} [textInput] - Text content + * @property {string} [textColor] - Text color + * @property {string} [textBackgroundColor] - Text background color + * @property {string} [color] - Element color + * @property {string} [background] - Element background + * @property {number} [cornerRadius] - Corner radius + * @property {boolean} [textBold] - Bold text flag + * @property {boolean} [textItalic] - Italic text flag + * @property {number} [fontSize] - Font size + * @property {string} [fontType] - Font type + * @property {string} [textAlignment] - Text alignment + * @property {number} [textLineSpacing] - Line spacing + * @property {number} [yOffset] - Vertical offset + * @property {StoriesProduct[]} [products] - Product carousel items + * @property {StoriesPromoCodeElement} [product] - Promocode product + * @property {Object} [labels] - Labels for buttons + * @property {string} [labels.showCarousel] - Label for show carousel button + * @property {string} [labels.hideCarousel] - Label for hide carousel button + */ + +/** + * @typedef {Object} StoriesProduct + * @property {string} name - Product name + * @property {string} currency - Currency code + * @property {number} price - Product price + * @property {number} price_full - Full price + * @property {string} price_formatted - Formatted price + * @property {string} price_full_formatted - Formatted full price + * @property {number} [oldprice] - Old price + * @property {string} oldprice_formatted - Formatted old price + * @property {string} picture - Product image URL + * @property {string} [discount] - Discount percentage + * @property {string} [discount_formatted] - Formatted discount + * @property {StoriesCategory} category - Product category + * @property {string} url - Product URL + * @property {string} deeplinkIos - iOS deeplink + * @property {string} [deeplinkAndroid] - Android deeplink + */ + +/** + * @typedef {Object} StoriesPromoCodeElement + * @property {string} id - Product ID + * @property {string} name - Product name + * @property {string} brand - Brand name + * @property {string} currency - Currency code + * @property {number} price - Product price + * @property {string} price_formatted - Formatted price + * @property {string} price_full_formatted - Formatted full price + * @property {number} oldprice - Old price + * @property {string} oldprice_formatted - Formatted old price + * @property {string} picture - Product image URL + * @property {string} url - Product URL + * @property {string} deeplinkIos - iOS deeplink + * @property {number} discount_percent - Discount percentage + * @property {string} price_with_promocode_formatted - Price with promocode + * @property {string} promocode - Promocode string + */ + +/** + * @typedef {Object} StoriesCategory + * @property {string} name - Category name + * @property {string} url - Category URL + */ + +/** + * @typedef {'image'|'video'|'unknown'} SlideType + */ + +/** + * @typedef {'button'|'product'|'text_block'|'unknown'} ElementType + */ + +/** + * @typedef {'left'|'right'|'center'} TextAlignment + */ + +/** + * @typedef {'monospaced'|'serif'|'sans-serif'|'unknown'} FontType + */ + +export { + // Types are exported for JSDoc usage +} diff --git a/lib/stories/videoCache.js b/lib/stories/videoCache.js new file mode 100644 index 0000000..e6078ea --- /dev/null +++ b/lib/stories/videoCache.js @@ -0,0 +1,588 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { Platform } from 'react-native' + +// Try to import react-native-fs, fallback to basic file operations if not available +let RNFS = null +try { + RNFS = require('react-native-fs') + if (__DEV__) { + console.log('[videoCache] react-native-fs loaded successfully') + } +} catch (error) { + if (__DEV__) { + console.warn('[videoCache] react-native-fs not available, using basic file operations', error) + } +} + +// Cache directory for videos +const CACHE_DIR = RNFS ? RNFS.CachesDirectoryPath + '/stories_videos' : null + +// Cache metadata keys +const VIDEO_METADATA_KEY = 'stories.videoCache.metadata' +const VIDEO_DURATION_KEY = 'stories.videoCache.durations' + +// Active downloads map for cancellation +const activeDownloads = new Map() + +/** + * Initialize cache directory + */ +async function initCacheDirectory() { + if (!RNFS || !CACHE_DIR) { + return false + } + + try { + const dirExists = await RNFS.exists(CACHE_DIR) + if (!dirExists) { + await RNFS.mkdir(CACHE_DIR) + } + return true + } catch (error) { + if (__DEV__) { + console.warn('[videoCache] Error initializing cache directory:', error) + } + return false + } +} + +/** + * Normalize slide ID to string (handle both string and number IDs) + * @param {string|number} slideId - Slide ID + * @returns {string} Normalized slide ID as string + */ +function normalizeSlideId(slideId) { + if (slideId === null || slideId === undefined) { + return null + } + return String(slideId) +} + +/** + * Get video file path for a slide + * @param {string|number} slideId - Slide ID + * @returns {string} File path + */ +function getVideoFilePath(slideId) { + if (!CACHE_DIR) { + return null + } + const normalizedId = normalizeSlideId(slideId) + if (!normalizedId) { + return null + } + return `${CACHE_DIR}/${normalizedId}.mp4` +} + +/** + * Get cached video metadata from AsyncStorage + * @returns {Promise>} + */ +async function getVideoMetadata() { + try { + const metadataStr = await AsyncStorage.getItem(VIDEO_METADATA_KEY) + if (metadataStr) { + const metadata = JSON.parse(metadataStr) + return new Map(Object.entries(metadata)) + } + } catch (error) { + if (__DEV__) { + console.warn('[videoCache] Error getting video metadata:', error) + } + } + return new Map() +} + +/** + * Save video metadata to AsyncStorage + * @param {Map} metadata + */ +async function saveVideoMetadata(metadata) { + try { + const metadataObj = Object.fromEntries(metadata) + await AsyncStorage.setItem(VIDEO_METADATA_KEY, JSON.stringify(metadataObj)) + } catch (error) { + if (__DEV__) { + console.warn('[videoCache] Error saving video metadata:', error) + } + } +} + +/** + * Get video duration from cache + * @param {string|number} slideId - Slide ID + * @returns {Promise} Duration in seconds or null + */ +async function getCachedVideoDuration(slideId) { + try { + const normalizedId = normalizeSlideId(slideId) + if (!normalizedId) { + return null + } + const durationsStr = await AsyncStorage.getItem(VIDEO_DURATION_KEY) + if (durationsStr) { + const durations = JSON.parse(durationsStr) + return durations[normalizedId] || null + } + } catch (error) { + if (__DEV__) { + console.warn('[videoCache] Error getting video duration:', error) + } + } + return null +} + +/** + * Save video duration to cache + * @param {string|number} slideId - Slide ID + * @param {number} duration - Duration in seconds + */ +async function saveVideoDuration(slideId, duration) { + try { + const normalizedId = normalizeSlideId(slideId) + if (!normalizedId) { + return + } + const durationsStr = await AsyncStorage.getItem(VIDEO_DURATION_KEY) + const durations = durationsStr ? JSON.parse(durationsStr) : {} + durations[normalizedId] = duration + await AsyncStorage.setItem(VIDEO_DURATION_KEY, JSON.stringify(durations)) + } catch (error) { + if (__DEV__) { + console.warn('[videoCache] Error saving video duration:', error) + } + } +} + +/** + * Check if video is cached + * @param {string} slideId - Slide ID + * @returns {Promise} True if video is cached + */ +export async function isVideoCached(slideId) { + if (!slideId) { + return false + } + + if (!RNFS || !CACHE_DIR) { + // If react-native-fs is not available, we can't cache videos + return false + } + + try { + // Normalize slideId to string for consistent comparison + const normalizedId = normalizeSlideId(slideId) + if (!normalizedId) { + return false + } + + const filePath = getVideoFilePath(normalizedId) + if (!filePath) { + if (__DEV__) { + console.warn(`[videoCache] No file path for slide ${normalizedId}`) + } + return false + } + + // Check metadata first (faster) + const metadata = await getVideoMetadata() + const hasMetadata = metadata.has(normalizedId) + + if (__DEV__) { + console.log(`[videoCache] Checking cache for slide ${normalizedId} (original: ${slideId}, type: ${typeof slideId}): metadata=${hasMetadata}, filePath=${filePath}`) + } + + // Check if file exists + const exists = await RNFS.exists(filePath) + + if (__DEV__) { + console.log(`[videoCache] File exists check for slide ${normalizedId}: ${exists}`) + } + + if (exists && hasMetadata) { + if (__DEV__) { + console.log(`[videoCache] Video is cached for slide ${normalizedId}`) + } + return true + } + + if (exists && !hasMetadata) { + // File exists but metadata is missing - add metadata + if (__DEV__) { + console.log(`[videoCache] File exists but metadata missing for slide ${normalizedId}, adding metadata`) + } + try { + const fileStats = await RNFS.stat(filePath) + const fileSize = fileStats.size || 0 + metadata.set(normalizedId, { + path: filePath, + timestamp: Date.now(), + size: fileSize, + }) + await saveVideoMetadata(metadata) + if (__DEV__) { + console.log(`[videoCache] Metadata added for slide ${normalizedId}`) + } + return true + } catch (error) { + if (__DEV__) { + console.warn(`[videoCache] Error adding metadata for slide ${normalizedId}:`, error) + } + } + } + + if (__DEV__) { + console.log(`[videoCache] Video not cached for slide ${normalizedId}: exists=${exists}, hasMetadata=${hasMetadata}`) + } + return false + } catch (error) { + if (__DEV__) { + console.warn('[videoCache] Error checking if video cached:', error) + } + return false + } +} + +/** + * Get cached video file path + * @param {string} slideId - Slide ID + * @param {string} url - Original video URL (for fallback) + * @returns {Promise} Cached file path or null + */ +export async function getCachedVideoPath(slideId, url) { + if (!slideId) { + return null + } + + // Normalize slideId to string + const normalizedId = normalizeSlideId(slideId) + if (!normalizedId) { + return null + } + + const isCached = await isVideoCached(normalizedId) + if (isCached) { + const filePath = getVideoFilePath(normalizedId) + return filePath + } + + return null +} + +/** + * Preload video with streaming download + * Uses fetch with streaming to avoid loading entire file into memory + * @param {string} slideId - Slide ID + * @param {string} url - Video URL + * @param {Function} [onProgress] - Progress callback (progress: number 0-1) + * @returns {Promise<{path: string, duration?: number}>} Object with cached path and optional duration + */ +export async function preloadVideo(slideId, url, onProgress) { + if (!slideId || !url) { + throw new Error('slideId and url are required') + } + + if (!RNFS || !CACHE_DIR) { + if (__DEV__) { + console.warn('[videoCache] react-native-fs not available, cannot cache video') + } + throw new Error('react-native-fs is required for video caching') + } + + // Normalize slideId to string + const normalizedId = normalizeSlideId(slideId) + if (!normalizedId) { + throw new Error('Invalid slideId') + } + + // Check if already cached + const alreadyCached = await isVideoCached(normalizedId) + if (alreadyCached) { + if (__DEV__) { + console.log(`[videoCache] Video already cached for slide ${normalizedId}`) + } + const filePath = getVideoFilePath(normalizedId) + const duration = await getCachedVideoDuration(normalizedId) + return { path: filePath, duration: duration || undefined } + } + + if (__DEV__) { + console.log(`[videoCache] Starting video download for slide ${normalizedId} from ${url}`) + } + + // Initialize cache directory + await initCacheDirectory() + + const filePath = getVideoFilePath(normalizedId) + const abortController = new AbortController() + let downloadJob = null + + // Store abort controller and download job for cancellation (use normalized ID) + activeDownloads.set(normalizedId, { abortController, downloadJob: null }) + + try { + // Use RNFS.downloadFile for efficient downloading and writing + // This handles streaming, progress, and file writing natively + downloadJob = RNFS.downloadFile({ + fromUrl: url, + toFile: filePath, + background: true, + discretionary: true, + cacheable: true, + progress: onProgress ? (res) => { + if (res.contentLength > 0 && res.bytesWritten > 0) { + const progress = res.bytesWritten / res.contentLength + onProgress(progress) + } + } : undefined, + }) + + // Store download job for cancellation (use normalized ID) + activeDownloads.set(normalizedId, { abortController, downloadJob }) + + const downloadResult = await downloadJob.promise + + if (downloadResult.statusCode !== 200) { + throw new Error(`Download failed with status ${downloadResult.statusCode}`) + } + + if (__DEV__) { + console.log(`[videoCache] Video downloaded successfully for slide ${normalizedId}, status: ${downloadResult.statusCode}`) + } + + // Get file size for metadata + const fileStats = await RNFS.stat(filePath) + const fileSize = fileStats.size || 0 + + if (__DEV__) { + console.log(`[videoCache] Video file size for slide ${normalizedId}: ${fileSize} bytes`) + } + + // Save metadata (use normalized ID) + const metadata = await getVideoMetadata() + metadata.set(normalizedId, { + path: filePath, + timestamp: Date.now(), + size: fileSize, + }) + await saveVideoMetadata(metadata) + + if (__DEV__) { + console.log(`[videoCache] Video cached successfully for slide ${normalizedId} at ${filePath}`) + // Verify metadata was saved + const verifyMetadata = await getVideoMetadata() + console.log(`[videoCache] Metadata verification for slide ${normalizedId}: ${verifyMetadata.has(normalizedId)}`) + // Also check all keys in metadata + console.log(`[videoCache] All cached slide IDs:`, Array.from(verifyMetadata.keys())) + } + + // Try to get video duration (this would require native module or video processing) + // For now, we'll skip duration extraction as it requires additional dependencies + // Duration can be set later via setVideoDuration() + + // Remove from active downloads + activeDownloads.delete(normalizedId) + + return { path: filePath } + } catch (error) { + // Remove from active downloads + activeDownloads.delete(normalizedId) + + // Clean up partial file on error + try { + const exists = await RNFS.exists(filePath) + if (exists) { + await RNFS.unlink(filePath) + } + } catch (cleanupError) { + // Ignore cleanup errors + } + + if (error.message === 'Video preload cancelled' || error.name === 'AbortError') { + throw new Error('Video preload cancelled') + } + + throw error + } +} + +/** + * Cancel video preload + * @param {string|number} slideId - Slide ID + */ +export async function cancelVideoPreload(slideId) { + const normalizedId = normalizeSlideId(slideId) + if (!normalizedId) { + return + } + const downloadInfo = activeDownloads.get(normalizedId) + if (downloadInfo) { + if (downloadInfo.abortController) { + downloadInfo.abortController.abort() + } + if (downloadInfo.downloadJob) { + downloadInfo.downloadJob.promise.catch(() => {}) // Ignore cancellation errors + // RNFS downloadFile doesn't have a direct cancel method, but abort signal should handle it + } + activeDownloads.delete(normalizedId) + } +} + +/** + * Set video duration for cached video + * @param {string|number} slideId - Slide ID + * @param {number} duration - Duration in seconds + */ +export async function setVideoDuration(slideId, duration) { + await saveVideoDuration(slideId, duration) +} + +/** + * Get video duration from cache + * @param {string|number} slideId - Slide ID + * @returns {Promise} Duration in seconds or null + */ +export async function getVideoDuration(slideId) { + return await getCachedVideoDuration(slideId) +} + +/** + * Clear video cache + * @param {string[]} [slideIds] - Optional array of slide IDs to clear. If not provided, clears all + */ +export async function clearVideoCache(slideIds = null) { + if (!RNFS || !CACHE_DIR) { + return + } + + try { + const metadata = await getVideoMetadata() + + if (slideIds && Array.isArray(slideIds)) { + // Clear specific videos + for (const slideId of slideIds) { + const filePath = getVideoFilePath(slideId) + if (filePath) { + try { + const exists = await RNFS.exists(filePath) + if (exists) { + await RNFS.unlink(filePath) + } + } catch (error) { + // Ignore individual file errors + } + } + metadata.delete(slideId) + } + } else { + // Clear all videos + const allSlideIds = Array.from(metadata.keys()) + for (const slideId of allSlideIds) { + const filePath = getVideoFilePath(slideId) + if (filePath) { + try { + const exists = await RNFS.exists(filePath) + if (exists) { + await RNFS.unlink(filePath) + } + } catch (error) { + // Ignore individual file errors + } + } + } + metadata.clear() + } + + await saveVideoMetadata(metadata) + } catch (error) { + if (__DEV__) { + console.warn('[videoCache] Error clearing cache:', error) + } + } +} + +/** + * Get cache size (total size of cached videos) + * @returns {Promise} Total size in bytes + */ +export async function getVideoCacheSize() { + if (!RNFS || !CACHE_DIR) { + return 0 + } + + try { + const metadata = await getVideoMetadata() + let totalSize = 0 + + for (const [slideId, info] of metadata.entries()) { + if (info.size) { + totalSize += info.size + } else { + // Try to get file size if not in metadata + const filePath = getVideoFilePath(slideId) + if (filePath) { + try { + const exists = await RNFS.exists(filePath) + if (exists) { + const stat = await RNFS.stat(filePath) + totalSize += stat.size || 0 + } + } catch (error) { + // Ignore errors + } + } + } + } + + return totalSize + } catch (error) { + if (__DEV__) { + console.warn('[videoCache] Error getting cache size:', error) + } + return 0 + } +} + +/** + * Clean up old video cache entries (older than specified days) + * @param {number} daysOld - Remove entries older than this many days + * @returns {Promise} Number of entries removed + */ +export async function cleanupOldVideoCache(daysOld = 7) { + if (!RNFS || !CACHE_DIR) { + return 0 + } + + try { + const metadata = await getVideoMetadata() + const now = Date.now() + const maxAge = daysOld * 24 * 60 * 60 * 1000 + let removedCount = 0 + + for (const [slideId, info] of metadata.entries()) { + if (now - info.timestamp > maxAge) { + const filePath = getVideoFilePath(slideId) + if (filePath) { + try { + const exists = await RNFS.exists(filePath) + if (exists) { + await RNFS.unlink(filePath) + } + } catch (error) { + // Ignore individual file errors + } + } + metadata.delete(slideId) + removedCount++ + } + } + + await saveVideoMetadata(metadata) + return removedCount + } catch (error) { + if (__DEV__) { + console.warn('[videoCache] Error cleaning up old cache:', error) + } + return 0 + } +} diff --git a/lib/tests/tracker.test.js b/lib/tests/tracker.test.js index b63dc9d..3cb0781 100644 --- a/lib/tests/tracker.test.js +++ b/lib/tests/tracker.test.js @@ -1,4 +1,4 @@ -import { convertParams } from '../tracker'; +import { convertParams } from '../tracker.js'; describe('Track', () => { const initialEventData = { diff --git a/lib/tests/utils.test.js b/lib/tests/utils.test.js index ee73a78..1255855 100644 --- a/lib/tests/utils.test.js +++ b/lib/tests/utils.test.js @@ -1,5 +1,5 @@ -import DataEncoder from "../utils"; -import { SEARCH_QUERY_KEYS } from '../../constants/search-keys.constants'; +import DataEncoder from "../utils.js"; +import { SEARCH_QUERY_KEYS } from '../../constants/search-keys.constants.js'; describe('DataEncoder', () => { let encoder; diff --git a/tests/search.test.js b/tests/search.test.js index a524c13..63eb6b3 100644 --- a/tests/search.test.js +++ b/tests/search.test.js @@ -1,4 +1,4 @@ -import REES46 from '../index' +import REES46 from '../index.js' jest.mock('react-native-device-info', () => {}) jest.mock('@react-native-firebase/messaging', () => {})