@xhub-short/core 0.1.0-beta.14 → 0.1.0-beta.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +107 -57
- package/dist/index.js +233 -140
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { INetworkAdapter, IVideoLoader, IPosterLoader, ILogger, VideoSource,
|
|
1
|
+
import { INetworkAdapter, IVideoLoader, IPosterLoader, ILogger, VideoSource, ContentItem, IDataSource, IStorage, VideoItem, PrefetchCacheData, IAnalytics, ISessionStorage, SessionSnapshot, IInteraction, CommentItem, ReplyItem, ICommentAdapter, InternalLogger, CommentAuthor, PlaylistData, PlaylistSummary, IPlaylistDataSource } from '@xhub-short/contracts';
|
|
2
2
|
export { SessionSnapshot } from '@xhub-short/contracts';
|
|
3
3
|
import { StoreApi } from 'zustand/vanilla';
|
|
4
4
|
|
|
@@ -313,9 +313,9 @@ declare class ResourceGovernor {
|
|
|
313
313
|
* Feed state for zustand store
|
|
314
314
|
*/
|
|
315
315
|
interface FeedState {
|
|
316
|
-
/** Normalized
|
|
317
|
-
itemsById: Map<string,
|
|
318
|
-
/** Ordered list of
|
|
316
|
+
/** Normalized content data - Map for O(1) lookup */
|
|
317
|
+
itemsById: Map<string, ContentItem>;
|
|
318
|
+
/** Ordered list of content IDs for rendering */
|
|
319
319
|
displayOrder: string[];
|
|
320
320
|
/** Initial loading state */
|
|
321
321
|
loading: boolean;
|
|
@@ -360,8 +360,8 @@ interface FeedConfig {
|
|
|
360
360
|
/** Whether to enable SWR pattern (default: true) */
|
|
361
361
|
enableSWR?: boolean;
|
|
362
362
|
/**
|
|
363
|
-
* Maximum number of
|
|
364
|
-
* When exceeded, older
|
|
363
|
+
* Maximum number of items to keep in memory cache
|
|
364
|
+
* When exceeded, older items will be evicted (LRU policy)
|
|
365
365
|
* @default 100
|
|
366
366
|
*/
|
|
367
367
|
maxCacheSize?: number;
|
|
@@ -399,11 +399,14 @@ interface PrefetchCacheConfig {
|
|
|
399
399
|
*/
|
|
400
400
|
enabled: boolean;
|
|
401
401
|
/**
|
|
402
|
-
* Maximum number of
|
|
402
|
+
* Maximum number of items to cache
|
|
403
403
|
* Higher = more instant content, but more storage
|
|
404
|
-
* @default 10
|
|
405
404
|
*/
|
|
406
|
-
|
|
405
|
+
maxItems: number;
|
|
406
|
+
/**
|
|
407
|
+
* @deprecated Use maxItems instead. Will be removed in v3.0
|
|
408
|
+
*/
|
|
409
|
+
maxVideos?: number;
|
|
407
410
|
/**
|
|
408
411
|
* Storage key for prefetch cache
|
|
409
412
|
* @default 'sv-prefetch-cache'
|
|
@@ -411,8 +414,8 @@ interface PrefetchCacheConfig {
|
|
|
411
414
|
storageKey: string;
|
|
412
415
|
/**
|
|
413
416
|
* Enable dynamic cache eviction
|
|
414
|
-
* When user scrolls past cached
|
|
415
|
-
* Prevents user from rewatching same
|
|
417
|
+
* When user scrolls past cached items, remove them from cache
|
|
418
|
+
* Prevents user from rewatching same items on reload
|
|
416
419
|
* @default true
|
|
417
420
|
*/
|
|
418
421
|
enableDynamicEviction: boolean;
|
|
@@ -429,7 +432,7 @@ interface PrefetchCacheConfig {
|
|
|
429
432
|
declare const DEFAULT_PREFETCH_CACHE_CONFIG: PrefetchCacheConfig;
|
|
430
433
|
|
|
431
434
|
/**
|
|
432
|
-
* FeedManager - Manages
|
|
435
|
+
* FeedManager - Manages content feed data with zustand/vanilla store
|
|
433
436
|
*
|
|
434
437
|
* Features:
|
|
435
438
|
* - Data normalization (Map for O(1) lookup + ordered IDs)
|
|
@@ -478,10 +481,16 @@ declare class FeedManager {
|
|
|
478
481
|
*/
|
|
479
482
|
private accessOrder;
|
|
480
483
|
/**
|
|
481
|
-
* Track
|
|
484
|
+
* Track items that have already triggered predictive preload
|
|
482
485
|
* to avoid duplicate requests.
|
|
483
486
|
*/
|
|
484
|
-
private
|
|
487
|
+
private preloadedItemIds;
|
|
488
|
+
/**
|
|
489
|
+
* Recommend feed snapshot — preserved when switching to playlist mode.
|
|
490
|
+
* Stored here (not in React refs) because FeedManager is a singleton
|
|
491
|
+
* that survives component remounts caused by conditional rendering.
|
|
492
|
+
*/
|
|
493
|
+
private recommendSnapshot;
|
|
485
494
|
constructor(dataSource: IDataSource, config?: FeedConfig, storage?: IStorage, prefetchConfig?: Partial<PrefetchCacheConfig>, logger?: ILogger | undefined);
|
|
486
495
|
/** Static memory cache for explicit prefetching */
|
|
487
496
|
private static globalMemoryCache;
|
|
@@ -558,29 +567,37 @@ declare class FeedManager {
|
|
|
558
567
|
/**
|
|
559
568
|
* Handle playback progress and trigger predictive preloading
|
|
560
569
|
*
|
|
561
|
-
* @param
|
|
570
|
+
* @param itemId - ID of the currently playing item
|
|
562
571
|
* @param progress - Current playback progress (0-1)
|
|
563
572
|
* @param governor - Resource governor to trigger preload
|
|
564
573
|
* @param threshold - Progress threshold to trigger preload (default: 0.2)
|
|
565
574
|
*/
|
|
566
|
-
handlePlaybackProgress(
|
|
575
|
+
handlePlaybackProgress(itemId: string, progress: number, governor: ResourceGovernor, threshold?: number): void;
|
|
576
|
+
getItem(id: string): ContentItem | undefined;
|
|
577
|
+
/**
|
|
578
|
+
* Get ordered list of items
|
|
579
|
+
*/
|
|
580
|
+
getItems(): ContentItem[];
|
|
567
581
|
/**
|
|
568
|
-
*
|
|
569
|
-
|
|
582
|
+
* Update an item in the feed (for optimistic updates)
|
|
583
|
+
*/
|
|
584
|
+
updateItem(id: string, updates: Partial<ContentItem>): void;
|
|
585
|
+
/**
|
|
586
|
+
* Replace all items in the feed (e.g. for Playlist synchronization)
|
|
587
|
+
*/
|
|
588
|
+
replaceItems(items: ContentItem[]): void;
|
|
589
|
+
/**
|
|
590
|
+
* @deprecated Use getItem instead
|
|
570
591
|
*/
|
|
571
592
|
getVideo(id: string): VideoItem | undefined;
|
|
572
593
|
/**
|
|
573
|
-
*
|
|
594
|
+
* @deprecated Use getItems instead
|
|
574
595
|
*/
|
|
575
596
|
getVideos(): VideoItem[];
|
|
576
597
|
/**
|
|
577
|
-
*
|
|
598
|
+
* @deprecated Use updateItem instead
|
|
578
599
|
*/
|
|
579
600
|
updateVideo(id: string, updates: Partial<VideoItem>): void;
|
|
580
|
-
/**
|
|
581
|
-
* Replace all items in the feed (e.g. for Playlist synchronization)
|
|
582
|
-
*/
|
|
583
|
-
replaceItems(items: VideoItem[]): void;
|
|
584
601
|
/**
|
|
585
602
|
* Remove an item from the feed
|
|
586
603
|
*
|
|
@@ -593,10 +610,10 @@ declare class FeedManager {
|
|
|
593
610
|
*
|
|
594
611
|
* @example
|
|
595
612
|
* ```typescript
|
|
596
|
-
* // User reports a
|
|
597
|
-
* const wasRemoved = feedManager.removeItem(
|
|
613
|
+
* // User reports a content item
|
|
614
|
+
* const wasRemoved = feedManager.removeItem(itemId);
|
|
598
615
|
* if (wasRemoved) {
|
|
599
|
-
* // Navigate to next
|
|
616
|
+
* // Navigate to next item
|
|
600
617
|
* }
|
|
601
618
|
* ```
|
|
602
619
|
*/
|
|
@@ -623,20 +640,37 @@ declare class FeedManager {
|
|
|
623
640
|
* Used by LifecycleManager to restore state without API call.
|
|
624
641
|
* This bypasses normal data flow for state restoration.
|
|
625
642
|
*
|
|
626
|
-
* @param items -
|
|
643
|
+
* @param items - Content items from snapshot
|
|
627
644
|
* @param cursor - Pagination cursor from snapshot
|
|
628
645
|
* @param options - Additional hydration options
|
|
629
646
|
*/
|
|
630
|
-
hydrateFromSnapshot(items:
|
|
647
|
+
hydrateFromSnapshot(items: ContentItem[], cursor: string | null, options?: {
|
|
631
648
|
/** Whether to mark data as stale for background revalidation */
|
|
632
649
|
markAsStale?: boolean;
|
|
633
650
|
}): void;
|
|
651
|
+
/**
|
|
652
|
+
* Save the current recommend feed state before switching to playlist mode.
|
|
653
|
+
*
|
|
654
|
+
* @param activeIndex - Current scroll position (active item index)
|
|
655
|
+
*/
|
|
656
|
+
saveRecommendSnapshot(activeIndex: number): void;
|
|
657
|
+
/**
|
|
658
|
+
* Restore the recommend feed state after exiting playlist mode.
|
|
659
|
+
* Delegates to hydrateFromSnapshot() internally to avoid duplicating hydration logic.
|
|
660
|
+
*
|
|
661
|
+
* @returns The saved activeIndex, or null if no snapshot was available
|
|
662
|
+
*/
|
|
663
|
+
restoreRecommendSnapshot(): number | null;
|
|
664
|
+
/**
|
|
665
|
+
* Check if a recommend snapshot exists (for conditional logic in hooks)
|
|
666
|
+
*/
|
|
667
|
+
hasRecommendSnapshot(): boolean;
|
|
634
668
|
/**
|
|
635
669
|
* Update prefetch cache with current feed tail
|
|
636
670
|
* Called automatically after loadInitial() and loadMore()
|
|
637
671
|
*
|
|
638
|
-
* Strategy: Cache the LAST N
|
|
639
|
-
* These are
|
|
672
|
+
* Strategy: Cache the LAST N items (tail of feed)
|
|
673
|
+
* These are items user hasn't seen yet, perfect for instant display
|
|
640
674
|
*/
|
|
641
675
|
updatePrefetchCache(): void;
|
|
642
676
|
/**
|
|
@@ -653,21 +687,37 @@ declare class FeedManager {
|
|
|
653
687
|
* Marks data as stale to trigger background revalidation
|
|
654
688
|
*/
|
|
655
689
|
hydrateFromPrefetchCache(cache: PrefetchCacheData): void;
|
|
690
|
+
/**
|
|
691
|
+
* Load prefetch cache synchronously (zero-flash optimization)
|
|
692
|
+
*
|
|
693
|
+
* Uses `storage.getSync()` for synchronous localStorage access.
|
|
694
|
+
* This allows hydrating the feed during React's synchronous render phase,
|
|
695
|
+
* preventing any flash of loading/empty state when cached data exists.
|
|
696
|
+
*
|
|
697
|
+
* Returns null if:
|
|
698
|
+
* - Prefetch cache is disabled
|
|
699
|
+
* - No storage adapter
|
|
700
|
+
* - Storage doesn't support sync reads (e.g., IndexedDB)
|
|
701
|
+
* - No cached data
|
|
702
|
+
*
|
|
703
|
+
* @returns Cached feed data or null
|
|
704
|
+
*/
|
|
705
|
+
loadPrefetchCacheSync(): PrefetchCacheData | null;
|
|
656
706
|
/**
|
|
657
707
|
* Clear prefetch cache
|
|
658
708
|
* Call when user logs out or data should be invalidated
|
|
659
709
|
*/
|
|
660
710
|
clearPrefetchCache(): Promise<void>;
|
|
661
711
|
/**
|
|
662
|
-
* Evict
|
|
712
|
+
* Evict items that user has scrolled past from prefetch cache
|
|
663
713
|
* Called when user's focusedIndex changes
|
|
664
714
|
*
|
|
665
|
-
* Strategy: Remove all
|
|
666
|
-
* This ensures user doesn't rewatch
|
|
715
|
+
* Strategy: Remove all items at or before current position
|
|
716
|
+
* This ensures user doesn't rewatch items on reload
|
|
667
717
|
*
|
|
668
|
-
* @param currentIndex - Current focused
|
|
718
|
+
* @param currentIndex - Current focused item index in feed
|
|
669
719
|
*/
|
|
670
|
-
|
|
720
|
+
evictViewedItemsFromCache(currentIndex: number): Promise<void>;
|
|
671
721
|
/**
|
|
672
722
|
* Get prefetch cache configuration (for external access)
|
|
673
723
|
*/
|
|
@@ -677,15 +727,15 @@ declare class FeedManager {
|
|
|
677
727
|
*/
|
|
678
728
|
private fetchWithRetry;
|
|
679
729
|
/**
|
|
680
|
-
* Add
|
|
730
|
+
* Add items with deduplication
|
|
681
731
|
* Triggers garbage collection if cache exceeds maxCacheSize
|
|
682
732
|
*/
|
|
683
|
-
private
|
|
733
|
+
private addItems;
|
|
684
734
|
/**
|
|
685
|
-
* Merge
|
|
686
|
-
* Updates existing
|
|
735
|
+
* Merge items (for SWR revalidation)
|
|
736
|
+
* Updates existing items, adds new ones at the beginning
|
|
687
737
|
*/
|
|
688
|
-
private
|
|
738
|
+
private mergeItems;
|
|
689
739
|
/**
|
|
690
740
|
* Handle and categorize errors
|
|
691
741
|
*/
|
|
@@ -702,9 +752,9 @@ declare class FeedManager {
|
|
|
702
752
|
* Run garbage collection using LRU (Least Recently Used) policy
|
|
703
753
|
*
|
|
704
754
|
* When cache size exceeds maxCacheSize:
|
|
705
|
-
* 1. Sort
|
|
706
|
-
* 2. Evict oldest
|
|
707
|
-
* 3. Keep
|
|
755
|
+
* 1. Sort items by last access time (oldest first)
|
|
756
|
+
* 2. Evict oldest items until cache is within limit
|
|
757
|
+
* 3. Keep items that are currently in viewport (most recent in displayOrder)
|
|
708
758
|
*
|
|
709
759
|
* @returns Number of evicted items
|
|
710
760
|
*/
|
|
@@ -1402,7 +1452,7 @@ declare class LifecycleManager {
|
|
|
1402
1452
|
* @param data.restoreFrame - Captured video frame at playback position (only saved if restorePlaybackPosition is enabled)
|
|
1403
1453
|
*/
|
|
1404
1454
|
saveSnapshot(data: {
|
|
1405
|
-
items:
|
|
1455
|
+
items: ContentItem[];
|
|
1406
1456
|
cursor: string | null;
|
|
1407
1457
|
focusedIndex: number;
|
|
1408
1458
|
scrollPosition?: number;
|
|
@@ -1601,7 +1651,7 @@ declare class OptimisticManager {
|
|
|
1601
1651
|
private readonly config;
|
|
1602
1652
|
/** Interaction adapter */
|
|
1603
1653
|
private readonly interaction?;
|
|
1604
|
-
/** Feed manager for updating
|
|
1654
|
+
/** Feed manager for updating item state */
|
|
1605
1655
|
private readonly feedManager?;
|
|
1606
1656
|
/** Logger adapter */
|
|
1607
1657
|
private readonly logger?;
|
|
@@ -1609,25 +1659,25 @@ declare class OptimisticManager {
|
|
|
1609
1659
|
private readonly eventListeners;
|
|
1610
1660
|
/** Retry timer */
|
|
1611
1661
|
private retryTimer;
|
|
1612
|
-
/** Debounce timers for like/unlike per
|
|
1662
|
+
/** Debounce timers for like/unlike per item */
|
|
1613
1663
|
private readonly likeDebounceTimers;
|
|
1614
|
-
/** Intended like state while debouncing (
|
|
1664
|
+
/** Intended like state while debouncing (itemId -> isLiked) */
|
|
1615
1665
|
private readonly intendedLikeState;
|
|
1616
1666
|
/** Debounce delay in ms */
|
|
1617
1667
|
private readonly debounceDelay;
|
|
1618
1668
|
constructor(config?: OptimisticConfig);
|
|
1619
1669
|
/**
|
|
1620
|
-
* Like
|
|
1670
|
+
* Like an item with optimistic update
|
|
1621
1671
|
*/
|
|
1622
|
-
like(
|
|
1672
|
+
like(itemId: string): Promise<boolean>;
|
|
1623
1673
|
/**
|
|
1624
|
-
* Unlike
|
|
1674
|
+
* Unlike an item with optimistic update
|
|
1625
1675
|
*/
|
|
1626
|
-
unlike(
|
|
1676
|
+
unlike(itemId: string): Promise<boolean>;
|
|
1627
1677
|
/**
|
|
1628
1678
|
* Toggle like state with DEBOUNCE
|
|
1629
1679
|
*/
|
|
1630
|
-
toggleLike(
|
|
1680
|
+
toggleLike(itemId: string): void;
|
|
1631
1681
|
/**
|
|
1632
1682
|
* Execute API call after debounce delay
|
|
1633
1683
|
*/
|
|
@@ -1643,9 +1693,9 @@ declare class OptimisticManager {
|
|
|
1643
1693
|
/**
|
|
1644
1694
|
* Toggle follow state
|
|
1645
1695
|
*/
|
|
1646
|
-
toggleFollow(
|
|
1647
|
-
follow(
|
|
1648
|
-
unfollow(
|
|
1696
|
+
toggleFollow(itemId: string): Promise<boolean>;
|
|
1697
|
+
follow(itemId: string): Promise<boolean>;
|
|
1698
|
+
unfollow(itemId: string): Promise<boolean>;
|
|
1649
1699
|
addEventListener(listener: OptimisticEventListener): () => void;
|
|
1650
1700
|
removeEventListener(listener: OptimisticEventListener): void;
|
|
1651
1701
|
/**
|
|
@@ -1653,7 +1703,7 @@ declare class OptimisticManager {
|
|
|
1653
1703
|
*/
|
|
1654
1704
|
reset(): void;
|
|
1655
1705
|
getPendingActions(): PendingAction[];
|
|
1656
|
-
hasPendingAction(
|
|
1706
|
+
hasPendingAction(itemId: string, type?: ActionType): boolean;
|
|
1657
1707
|
getFailedQueue(): PendingAction[];
|
|
1658
1708
|
retryFailed(): Promise<void>;
|
|
1659
1709
|
clearFailed(): void;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// ../../node_modules/.pnpm/zustand@5.0.
|
|
1
|
+
// ../../node_modules/.pnpm/zustand@5.0.11_@types+react@19.2.14_react@19.2.4/node_modules/zustand/esm/vanilla.mjs
|
|
2
2
|
var createStoreImpl = (createState) => {
|
|
3
3
|
let state;
|
|
4
4
|
const listeners = /* @__PURE__ */ new Set();
|
|
@@ -35,6 +35,7 @@ var DEFAULT_FEED_CONFIG = {
|
|
|
35
35
|
};
|
|
36
36
|
var DEFAULT_PREFETCH_CACHE_CONFIG = {
|
|
37
37
|
enabled: true,
|
|
38
|
+
maxItems: 10,
|
|
38
39
|
maxVideos: 10,
|
|
39
40
|
storageKey: "sv-prefetch-cache",
|
|
40
41
|
enableDynamicEviction: true,
|
|
@@ -70,10 +71,16 @@ var _FeedManager = class _FeedManager {
|
|
|
70
71
|
*/
|
|
71
72
|
this.accessOrder = /* @__PURE__ */ new Map();
|
|
72
73
|
/**
|
|
73
|
-
* Track
|
|
74
|
+
* Track items that have already triggered predictive preload
|
|
74
75
|
* to avoid duplicate requests.
|
|
75
76
|
*/
|
|
76
|
-
this.
|
|
77
|
+
this.preloadedItemIds = /* @__PURE__ */ new Set();
|
|
78
|
+
/**
|
|
79
|
+
* Recommend feed snapshot — preserved when switching to playlist mode.
|
|
80
|
+
* Stored here (not in React refs) because FeedManager is a singleton
|
|
81
|
+
* that survives component remounts caused by conditional rendering.
|
|
82
|
+
*/
|
|
83
|
+
this.recommendSnapshot = null;
|
|
77
84
|
this.config = { ...DEFAULT_FEED_CONFIG, ...config };
|
|
78
85
|
this.prefetchConfig = { ...DEFAULT_PREFETCH_CACHE_CONFIG, ...prefetchConfig };
|
|
79
86
|
this.storage = storage ?? null;
|
|
@@ -240,7 +247,7 @@ var _FeedManager = class _FeedManager {
|
|
|
240
247
|
}
|
|
241
248
|
try {
|
|
242
249
|
const response = await this.dataSource.fetchFeed();
|
|
243
|
-
this.
|
|
250
|
+
this.mergeItems(response.items, true);
|
|
244
251
|
this.store.setState({
|
|
245
252
|
cursor: response.nextCursor,
|
|
246
253
|
hasMore: response.hasMore,
|
|
@@ -253,17 +260,17 @@ var _FeedManager = class _FeedManager {
|
|
|
253
260
|
/**
|
|
254
261
|
* Handle playback progress and trigger predictive preloading
|
|
255
262
|
*
|
|
256
|
-
* @param
|
|
263
|
+
* @param itemId - ID of the currently playing item
|
|
257
264
|
* @param progress - Current playback progress (0-1)
|
|
258
265
|
* @param governor - Resource governor to trigger preload
|
|
259
266
|
* @param threshold - Progress threshold to trigger preload (default: 0.2)
|
|
260
267
|
*/
|
|
261
|
-
handlePlaybackProgress(
|
|
262
|
-
if (this.
|
|
268
|
+
handlePlaybackProgress(itemId, progress, governor, threshold = 0.2) {
|
|
269
|
+
if (this.preloadedItemIds.has(itemId)) return;
|
|
263
270
|
if (progress >= threshold) {
|
|
264
|
-
this.
|
|
271
|
+
this.preloadedItemIds.add(itemId);
|
|
265
272
|
const state = this.store.getState();
|
|
266
|
-
const currentIndex = state.displayOrder.indexOf(
|
|
273
|
+
const currentIndex = state.displayOrder.indexOf(itemId);
|
|
267
274
|
if (currentIndex !== -1) {
|
|
268
275
|
const nextIndices = [currentIndex + 1, currentIndex + 2].filter(
|
|
269
276
|
(idx) => idx < state.displayOrder.length
|
|
@@ -277,28 +284,24 @@ var _FeedManager = class _FeedManager {
|
|
|
277
284
|
}
|
|
278
285
|
}
|
|
279
286
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
*/
|
|
284
|
-
getVideo(id) {
|
|
285
|
-
const video = this.store.getState().itemsById.get(id);
|
|
286
|
-
if (video) {
|
|
287
|
+
getItem(id) {
|
|
288
|
+
const item = this.store.getState().itemsById.get(id);
|
|
289
|
+
if (item) {
|
|
287
290
|
this.accessOrder.set(id, Date.now());
|
|
288
291
|
}
|
|
289
|
-
return
|
|
292
|
+
return item;
|
|
290
293
|
}
|
|
291
294
|
/**
|
|
292
|
-
* Get ordered list of
|
|
295
|
+
* Get ordered list of items
|
|
293
296
|
*/
|
|
294
|
-
|
|
297
|
+
getItems() {
|
|
295
298
|
const state = this.store.getState();
|
|
296
299
|
return state.displayOrder.map((id) => state.itemsById.get(id)).filter((v) => v !== void 0);
|
|
297
300
|
}
|
|
298
301
|
/**
|
|
299
|
-
* Update
|
|
302
|
+
* Update an item in the feed (for optimistic updates)
|
|
300
303
|
*/
|
|
301
|
-
|
|
304
|
+
updateItem(id, updates) {
|
|
302
305
|
const state = this.store.getState();
|
|
303
306
|
const existing = state.itemsById.get(id);
|
|
304
307
|
if (existing) {
|
|
@@ -326,6 +329,24 @@ var _FeedManager = class _FeedManager {
|
|
|
326
329
|
hasMore: false
|
|
327
330
|
});
|
|
328
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* @deprecated Use getItem instead
|
|
334
|
+
*/
|
|
335
|
+
getVideo(id) {
|
|
336
|
+
return this.getItem(id);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* @deprecated Use getItems instead
|
|
340
|
+
*/
|
|
341
|
+
getVideos() {
|
|
342
|
+
return this.getItems();
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* @deprecated Use updateItem instead
|
|
346
|
+
*/
|
|
347
|
+
updateVideo(id, updates) {
|
|
348
|
+
this.updateItem(id, updates);
|
|
349
|
+
}
|
|
329
350
|
/**
|
|
330
351
|
* Remove an item from the feed
|
|
331
352
|
*
|
|
@@ -338,10 +359,10 @@ var _FeedManager = class _FeedManager {
|
|
|
338
359
|
*
|
|
339
360
|
* @example
|
|
340
361
|
* ```typescript
|
|
341
|
-
* // User reports a
|
|
342
|
-
* const wasRemoved = feedManager.removeItem(
|
|
362
|
+
* // User reports a content item
|
|
363
|
+
* const wasRemoved = feedManager.removeItem(itemId);
|
|
343
364
|
* if (wasRemoved) {
|
|
344
|
-
* // Navigate to next
|
|
365
|
+
* // Navigate to next item
|
|
345
366
|
* }
|
|
346
367
|
* ```
|
|
347
368
|
*/
|
|
@@ -375,7 +396,7 @@ var _FeedManager = class _FeedManager {
|
|
|
375
396
|
this.cancelPendingRequests();
|
|
376
397
|
this.inFlightRequests.clear();
|
|
377
398
|
this.accessOrder.clear();
|
|
378
|
-
this.
|
|
399
|
+
this.preloadedItemIds.clear();
|
|
379
400
|
this.store.setState(createInitialState());
|
|
380
401
|
}
|
|
381
402
|
/**
|
|
@@ -402,7 +423,7 @@ var _FeedManager = class _FeedManager {
|
|
|
402
423
|
* Used by LifecycleManager to restore state without API call.
|
|
403
424
|
* This bypasses normal data flow for state restoration.
|
|
404
425
|
*
|
|
405
|
-
* @param items -
|
|
426
|
+
* @param items - Content items from snapshot
|
|
406
427
|
* @param cursor - Pagination cursor from snapshot
|
|
407
428
|
* @param options - Additional hydration options
|
|
408
429
|
*/
|
|
@@ -413,10 +434,10 @@ var _FeedManager = class _FeedManager {
|
|
|
413
434
|
const now = Date.now();
|
|
414
435
|
const newItemsById = /* @__PURE__ */ new Map();
|
|
415
436
|
const newDisplayOrder = [];
|
|
416
|
-
for (const
|
|
417
|
-
newItemsById.set(
|
|
418
|
-
newDisplayOrder.push(
|
|
419
|
-
this.accessOrder.set(
|
|
437
|
+
for (const item of items) {
|
|
438
|
+
newItemsById.set(item.id, item);
|
|
439
|
+
newDisplayOrder.push(item.id);
|
|
440
|
+
this.accessOrder.set(item.id, now);
|
|
420
441
|
}
|
|
421
442
|
this.store.setState({
|
|
422
443
|
itemsById: newItemsById,
|
|
@@ -431,26 +452,67 @@ var _FeedManager = class _FeedManager {
|
|
|
431
452
|
});
|
|
432
453
|
}
|
|
433
454
|
// ═══════════════════════════════════════════════════════════════
|
|
455
|
+
// RECOMMEND SNAPSHOT (for playlist ↔ recommend switching)
|
|
456
|
+
// ═══════════════════════════════════════════════════════════════
|
|
457
|
+
/**
|
|
458
|
+
* Save the current recommend feed state before switching to playlist mode.
|
|
459
|
+
*
|
|
460
|
+
* @param activeIndex - Current scroll position (active item index)
|
|
461
|
+
*/
|
|
462
|
+
saveRecommendSnapshot(activeIndex) {
|
|
463
|
+
this.recommendSnapshot = {
|
|
464
|
+
items: this.getItems(),
|
|
465
|
+
cursor: this.store.getState().cursor,
|
|
466
|
+
hasMore: this.store.getState().hasMore,
|
|
467
|
+
activeIndex
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Restore the recommend feed state after exiting playlist mode.
|
|
472
|
+
* Delegates to hydrateFromSnapshot() internally to avoid duplicating hydration logic.
|
|
473
|
+
*
|
|
474
|
+
* @returns The saved activeIndex, or null if no snapshot was available
|
|
475
|
+
*/
|
|
476
|
+
restoreRecommendSnapshot() {
|
|
477
|
+
const snapshot = this.recommendSnapshot;
|
|
478
|
+
if (!snapshot || snapshot.items.length === 0) {
|
|
479
|
+
this.recommendSnapshot = null;
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
this.hydrateFromSnapshot(snapshot.items, snapshot.cursor, {
|
|
483
|
+
markAsStale: false
|
|
484
|
+
});
|
|
485
|
+
const savedIndex = snapshot.activeIndex;
|
|
486
|
+
this.recommendSnapshot = null;
|
|
487
|
+
return savedIndex;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Check if a recommend snapshot exists (for conditional logic in hooks)
|
|
491
|
+
*/
|
|
492
|
+
hasRecommendSnapshot() {
|
|
493
|
+
return this.recommendSnapshot !== null && this.recommendSnapshot.items.length > 0;
|
|
494
|
+
}
|
|
495
|
+
// ═══════════════════════════════════════════════════════════════
|
|
434
496
|
// PREFETCH CACHE METHODS
|
|
435
497
|
// ═══════════════════════════════════════════════════════════════
|
|
436
498
|
/**
|
|
437
499
|
* Update prefetch cache with current feed tail
|
|
438
500
|
* Called automatically after loadInitial() and loadMore()
|
|
439
501
|
*
|
|
440
|
-
* Strategy: Cache the LAST N
|
|
441
|
-
* These are
|
|
502
|
+
* Strategy: Cache the LAST N items (tail of feed)
|
|
503
|
+
* These are items user hasn't seen yet, perfect for instant display
|
|
442
504
|
*/
|
|
443
505
|
updatePrefetchCache() {
|
|
444
506
|
if (!this.prefetchConfig.enabled) return;
|
|
445
507
|
if (!this.storage) return;
|
|
446
508
|
const state = this.store.getState();
|
|
447
|
-
const
|
|
448
|
-
if (
|
|
509
|
+
const allItems = this.getItems();
|
|
510
|
+
if (allItems.length < this.prefetchConfig.maxItems) {
|
|
449
511
|
return;
|
|
450
512
|
}
|
|
451
|
-
const
|
|
513
|
+
const tailItems = allItems.slice(-this.prefetchConfig.maxItems);
|
|
452
514
|
const cacheData = {
|
|
453
|
-
items:
|
|
515
|
+
items: tailItems,
|
|
454
516
|
savedAt: Date.now(),
|
|
455
517
|
cursor: state.cursor
|
|
456
518
|
};
|
|
@@ -493,6 +555,35 @@ var _FeedManager = class _FeedManager {
|
|
|
493
555
|
// Trigger revalidation
|
|
494
556
|
});
|
|
495
557
|
}
|
|
558
|
+
/**
|
|
559
|
+
* Load prefetch cache synchronously (zero-flash optimization)
|
|
560
|
+
*
|
|
561
|
+
* Uses `storage.getSync()` for synchronous localStorage access.
|
|
562
|
+
* This allows hydrating the feed during React's synchronous render phase,
|
|
563
|
+
* preventing any flash of loading/empty state when cached data exists.
|
|
564
|
+
*
|
|
565
|
+
* Returns null if:
|
|
566
|
+
* - Prefetch cache is disabled
|
|
567
|
+
* - No storage adapter
|
|
568
|
+
* - Storage doesn't support sync reads (e.g., IndexedDB)
|
|
569
|
+
* - No cached data
|
|
570
|
+
*
|
|
571
|
+
* @returns Cached feed data or null
|
|
572
|
+
*/
|
|
573
|
+
loadPrefetchCacheSync() {
|
|
574
|
+
if (!this.prefetchConfig.enabled) return null;
|
|
575
|
+
if (!this.storage) return null;
|
|
576
|
+
if (!this.storage.getSync) return null;
|
|
577
|
+
try {
|
|
578
|
+
const data = this.storage.getSync(this.prefetchConfig.storageKey);
|
|
579
|
+
if (!data || !data.items || data.items.length === 0) {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
return data;
|
|
583
|
+
} catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
496
587
|
/**
|
|
497
588
|
* Clear prefetch cache
|
|
498
589
|
* Call when user logs out or data should be invalidated
|
|
@@ -505,26 +596,26 @@ var _FeedManager = class _FeedManager {
|
|
|
505
596
|
}
|
|
506
597
|
}
|
|
507
598
|
/**
|
|
508
|
-
* Evict
|
|
599
|
+
* Evict items that user has scrolled past from prefetch cache
|
|
509
600
|
* Called when user's focusedIndex changes
|
|
510
601
|
*
|
|
511
|
-
* Strategy: Remove all
|
|
512
|
-
* This ensures user doesn't rewatch
|
|
602
|
+
* Strategy: Remove all items at or before current position
|
|
603
|
+
* This ensures user doesn't rewatch items on reload
|
|
513
604
|
*
|
|
514
|
-
* @param currentIndex - Current focused
|
|
605
|
+
* @param currentIndex - Current focused item index in feed
|
|
515
606
|
*/
|
|
516
|
-
async
|
|
607
|
+
async evictViewedItemsFromCache(currentIndex) {
|
|
517
608
|
if (!this.prefetchConfig.enabled) return;
|
|
518
609
|
if (!this.prefetchConfig.enableDynamicEviction) return;
|
|
519
610
|
if (!this.storage) return;
|
|
520
611
|
try {
|
|
521
612
|
const cache = await this.loadPrefetchCache();
|
|
522
613
|
if (!cache || cache.items.length === 0) return;
|
|
523
|
-
const
|
|
524
|
-
const
|
|
525
|
-
if (!
|
|
526
|
-
const
|
|
527
|
-
const updatedItems = cache.items.filter((item) => !
|
|
614
|
+
const allItems = this.getItems();
|
|
615
|
+
const currentItem = allItems[currentIndex];
|
|
616
|
+
if (!currentItem) return;
|
|
617
|
+
const viewedItemIds = new Set(allItems.slice(0, currentIndex + 1).map((v) => v.id));
|
|
618
|
+
const updatedItems = cache.items.filter((item) => !viewedItemIds.has(item.id));
|
|
528
619
|
if (updatedItems.length === cache.items.length) return;
|
|
529
620
|
if (updatedItems.length === 0) {
|
|
530
621
|
await this.storage.remove(this.prefetchConfig.storageKey);
|
|
@@ -560,7 +651,7 @@ var _FeedManager = class _FeedManager {
|
|
|
560
651
|
if (replace) {
|
|
561
652
|
this.replaceItems(response.items);
|
|
562
653
|
} else {
|
|
563
|
-
this.
|
|
654
|
+
this.addItems(response.items);
|
|
564
655
|
}
|
|
565
656
|
this.store.setState({
|
|
566
657
|
cursor: response.nextCursor,
|
|
@@ -584,23 +675,23 @@ var _FeedManager = class _FeedManager {
|
|
|
584
675
|
throw lastError;
|
|
585
676
|
}
|
|
586
677
|
/**
|
|
587
|
-
* Add
|
|
678
|
+
* Add items with deduplication
|
|
588
679
|
* Triggers garbage collection if cache exceeds maxCacheSize
|
|
589
680
|
*/
|
|
590
|
-
|
|
681
|
+
addItems(items) {
|
|
591
682
|
const state = this.store.getState();
|
|
592
683
|
const newItemsById = new Map(state.itemsById);
|
|
593
684
|
const newDisplayOrder = [...state.displayOrder];
|
|
594
685
|
const now = Date.now();
|
|
595
|
-
for (const
|
|
596
|
-
const existing = newItemsById.get(
|
|
686
|
+
for (const item of items) {
|
|
687
|
+
const existing = newItemsById.get(item.id);
|
|
597
688
|
if (existing) {
|
|
598
|
-
newItemsById.set(
|
|
689
|
+
newItemsById.set(item.id, { ...existing, ...item });
|
|
599
690
|
} else {
|
|
600
|
-
newItemsById.set(
|
|
601
|
-
newDisplayOrder.push(
|
|
691
|
+
newItemsById.set(item.id, item);
|
|
692
|
+
newDisplayOrder.push(item.id);
|
|
602
693
|
}
|
|
603
|
-
this.accessOrder.set(
|
|
694
|
+
this.accessOrder.set(item.id, now);
|
|
604
695
|
}
|
|
605
696
|
this.store.setState({
|
|
606
697
|
itemsById: newItemsById,
|
|
@@ -611,19 +702,19 @@ var _FeedManager = class _FeedManager {
|
|
|
611
702
|
}
|
|
612
703
|
}
|
|
613
704
|
/**
|
|
614
|
-
* Merge
|
|
615
|
-
* Updates existing
|
|
705
|
+
* Merge items (for SWR revalidation)
|
|
706
|
+
* Updates existing items, adds new ones at the beginning
|
|
616
707
|
*/
|
|
617
|
-
|
|
708
|
+
mergeItems(items, prepend) {
|
|
618
709
|
const state = this.store.getState();
|
|
619
710
|
const newItemsById = new Map(state.itemsById);
|
|
620
711
|
const newIds = [];
|
|
621
|
-
for (const
|
|
622
|
-
if (newItemsById.has(
|
|
623
|
-
newItemsById.set(
|
|
712
|
+
for (const item of items) {
|
|
713
|
+
if (newItemsById.has(item.id)) {
|
|
714
|
+
newItemsById.set(item.id, item);
|
|
624
715
|
} else {
|
|
625
|
-
newItemsById.set(
|
|
626
|
-
newIds.push(
|
|
716
|
+
newItemsById.set(item.id, item);
|
|
717
|
+
newIds.push(item.id);
|
|
627
718
|
}
|
|
628
719
|
}
|
|
629
720
|
const newDisplayOrder = prepend ? [...newIds, ...state.displayOrder] : [...state.displayOrder, ...newIds];
|
|
@@ -696,9 +787,9 @@ var _FeedManager = class _FeedManager {
|
|
|
696
787
|
* Run garbage collection using LRU (Least Recently Used) policy
|
|
697
788
|
*
|
|
698
789
|
* When cache size exceeds maxCacheSize:
|
|
699
|
-
* 1. Sort
|
|
700
|
-
* 2. Evict oldest
|
|
701
|
-
* 3. Keep
|
|
790
|
+
* 1. Sort items by last access time (oldest first)
|
|
791
|
+
* 2. Evict oldest items until cache is within limit
|
|
792
|
+
* 3. Keep items that are currently in viewport (most recent in displayOrder)
|
|
702
793
|
*
|
|
703
794
|
* @returns Number of evicted items
|
|
704
795
|
*/
|
|
@@ -2577,9 +2668,9 @@ var OptimisticManager = class {
|
|
|
2577
2668
|
this.eventListeners = /* @__PURE__ */ new Set();
|
|
2578
2669
|
/** Retry timer */
|
|
2579
2670
|
this.retryTimer = null;
|
|
2580
|
-
/** Debounce timers for like/unlike per
|
|
2671
|
+
/** Debounce timers for like/unlike per item */
|
|
2581
2672
|
this.likeDebounceTimers = /* @__PURE__ */ new Map();
|
|
2582
|
-
/** Intended like state while debouncing (
|
|
2673
|
+
/** Intended like state while debouncing (itemId -> isLiked) */
|
|
2583
2674
|
this.intendedLikeState = /* @__PURE__ */ new Map();
|
|
2584
2675
|
/** Debounce delay in ms */
|
|
2585
2676
|
this.debounceDelay = 300;
|
|
@@ -2594,73 +2685,75 @@ var OptimisticManager = class {
|
|
|
2594
2685
|
// PUBLIC API - ACTIONS
|
|
2595
2686
|
// ═══════════════════════════════════════════════════════════════
|
|
2596
2687
|
/**
|
|
2597
|
-
* Like
|
|
2688
|
+
* Like an item with optimistic update
|
|
2598
2689
|
*/
|
|
2599
|
-
async like(
|
|
2600
|
-
return this.performAction("like",
|
|
2690
|
+
async like(itemId) {
|
|
2691
|
+
return this.performAction("like", itemId, async () => {
|
|
2601
2692
|
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2602
|
-
await this.interaction.like(
|
|
2693
|
+
await this.interaction.like(itemId);
|
|
2603
2694
|
});
|
|
2604
2695
|
}
|
|
2605
2696
|
/**
|
|
2606
|
-
* Unlike
|
|
2697
|
+
* Unlike an item with optimistic update
|
|
2607
2698
|
*/
|
|
2608
|
-
async unlike(
|
|
2609
|
-
return this.performAction("unlike",
|
|
2699
|
+
async unlike(itemId) {
|
|
2700
|
+
return this.performAction("unlike", itemId, async () => {
|
|
2610
2701
|
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2611
|
-
await this.interaction.unlike(
|
|
2702
|
+
await this.interaction.unlike(itemId);
|
|
2612
2703
|
});
|
|
2613
2704
|
}
|
|
2614
2705
|
/**
|
|
2615
2706
|
* Toggle like state with DEBOUNCE
|
|
2616
2707
|
*/
|
|
2617
|
-
toggleLike(
|
|
2618
|
-
const
|
|
2619
|
-
if (!
|
|
2620
|
-
this.logger?.warn(`[OptimisticManager]
|
|
2708
|
+
toggleLike(itemId) {
|
|
2709
|
+
const item = this.feedManager?.getItem(itemId);
|
|
2710
|
+
if (!item) {
|
|
2711
|
+
this.logger?.warn(`[OptimisticManager] Item not found for toggleLike: ${itemId}`);
|
|
2621
2712
|
return;
|
|
2622
2713
|
}
|
|
2623
|
-
const currentIntended = this.intendedLikeState.get(
|
|
2714
|
+
const currentIntended = this.intendedLikeState.get(itemId) ?? item.isLiked;
|
|
2624
2715
|
const newIsLiked = !currentIntended;
|
|
2625
|
-
this.intendedLikeState.set(
|
|
2716
|
+
this.intendedLikeState.set(itemId, newIsLiked);
|
|
2626
2717
|
const likeDelta = newIsLiked ? 1 : -1;
|
|
2627
|
-
const currentLikesFromStore =
|
|
2628
|
-
this.feedManager?.
|
|
2718
|
+
const currentLikesFromStore = item.stats.likes;
|
|
2719
|
+
this.feedManager?.updateItem(itemId, {
|
|
2629
2720
|
isLiked: newIsLiked,
|
|
2630
|
-
stats: { ...
|
|
2721
|
+
stats: { ...item.stats, likes: Math.max(0, currentLikesFromStore + likeDelta) }
|
|
2631
2722
|
});
|
|
2632
|
-
const actionId = `like-debounce-${
|
|
2723
|
+
const actionId = `like-debounce-${itemId}`;
|
|
2633
2724
|
this.store.setState((state) => {
|
|
2634
2725
|
const pendingActions = new Map(state.pendingActions);
|
|
2635
2726
|
pendingActions.set(actionId, {
|
|
2636
2727
|
id: actionId,
|
|
2637
|
-
videoId,
|
|
2728
|
+
videoId: itemId,
|
|
2729
|
+
// Keep property name 'videoId' in PendingAction for compatibility if needed, but using itemId value
|
|
2638
2730
|
type: newIsLiked ? "like" : "unlike",
|
|
2639
2731
|
status: "pending",
|
|
2640
2732
|
timestamp: Date.now(),
|
|
2641
2733
|
retryCount: 0,
|
|
2642
2734
|
rollbackData: {
|
|
2643
2735
|
isLiked: currentIntended,
|
|
2644
|
-
stats: { ...
|
|
2736
|
+
stats: { ...item.stats }
|
|
2645
2737
|
}
|
|
2738
|
+
// Cast back to VideoItem if needed for contract
|
|
2646
2739
|
});
|
|
2647
2740
|
return { pendingActions, hasPending: true };
|
|
2648
2741
|
});
|
|
2649
|
-
const existingTimer = this.likeDebounceTimers.get(
|
|
2742
|
+
const existingTimer = this.likeDebounceTimers.get(itemId);
|
|
2650
2743
|
if (existingTimer) {
|
|
2651
2744
|
clearTimeout(existingTimer);
|
|
2652
2745
|
}
|
|
2653
2746
|
const timer = setTimeout(() => {
|
|
2654
|
-
this.likeDebounceTimers.delete(
|
|
2655
|
-
this.executeDebouncedLikeApi(
|
|
2747
|
+
this.likeDebounceTimers.delete(itemId);
|
|
2748
|
+
this.executeDebouncedLikeApi(itemId, actionId);
|
|
2656
2749
|
}, this.debounceDelay);
|
|
2657
|
-
this.likeDebounceTimers.set(
|
|
2750
|
+
this.likeDebounceTimers.set(itemId, timer);
|
|
2658
2751
|
}
|
|
2659
2752
|
/**
|
|
2660
2753
|
* Execute API call after debounce delay
|
|
2661
2754
|
*/
|
|
2662
|
-
async executeDebouncedLikeApi(
|
|
2663
|
-
const finalIntended = this.intendedLikeState.get(
|
|
2755
|
+
async executeDebouncedLikeApi(itemId, actionId) {
|
|
2756
|
+
const finalIntended = this.intendedLikeState.get(itemId);
|
|
2664
2757
|
if (finalIntended === void 0) {
|
|
2665
2758
|
this.removePendingAction(actionId);
|
|
2666
2759
|
return;
|
|
@@ -2668,15 +2761,15 @@ var OptimisticManager = class {
|
|
|
2668
2761
|
try {
|
|
2669
2762
|
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2670
2763
|
if (finalIntended) {
|
|
2671
|
-
await this.interaction.like(
|
|
2764
|
+
await this.interaction.like(itemId);
|
|
2672
2765
|
} else {
|
|
2673
|
-
await this.interaction.unlike(
|
|
2766
|
+
await this.interaction.unlike(itemId);
|
|
2674
2767
|
}
|
|
2675
|
-
if (this.intendedLikeState.get(
|
|
2676
|
-
this.intendedLikeState.delete(
|
|
2768
|
+
if (this.intendedLikeState.get(itemId) === finalIntended) {
|
|
2769
|
+
this.intendedLikeState.delete(itemId);
|
|
2677
2770
|
}
|
|
2678
2771
|
} catch (error) {
|
|
2679
|
-
this.handleDebouncedApiError(
|
|
2772
|
+
this.handleDebouncedApiError(itemId, finalIntended, error);
|
|
2680
2773
|
} finally {
|
|
2681
2774
|
this.removePendingAction(actionId);
|
|
2682
2775
|
}
|
|
@@ -2684,19 +2777,19 @@ var OptimisticManager = class {
|
|
|
2684
2777
|
/**
|
|
2685
2778
|
* Handle errors from debounced API calls
|
|
2686
2779
|
*/
|
|
2687
|
-
handleDebouncedApiError(
|
|
2780
|
+
handleDebouncedApiError(itemId, finalIntended, error) {
|
|
2688
2781
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
2689
|
-
this.logger?.error(`[OptimisticManager] API failed for ${
|
|
2690
|
-
if (!this.intendedLikeState.has(
|
|
2691
|
-
const
|
|
2692
|
-
if (
|
|
2782
|
+
this.logger?.error(`[OptimisticManager] API failed for ${itemId}`, err);
|
|
2783
|
+
if (!this.intendedLikeState.has(itemId)) {
|
|
2784
|
+
const currentItem = this.feedManager?.getItem(itemId);
|
|
2785
|
+
if (currentItem) {
|
|
2693
2786
|
const rollbackIsLiked = !finalIntended;
|
|
2694
2787
|
const rollbackDelta = rollbackIsLiked ? 1 : -1;
|
|
2695
|
-
this.feedManager?.
|
|
2788
|
+
this.feedManager?.updateItem(itemId, {
|
|
2696
2789
|
isLiked: rollbackIsLiked,
|
|
2697
2790
|
stats: {
|
|
2698
|
-
...
|
|
2699
|
-
likes: Math.max(0,
|
|
2791
|
+
...currentItem.stats,
|
|
2792
|
+
likes: Math.max(0, currentItem.stats.likes + rollbackDelta)
|
|
2700
2793
|
}
|
|
2701
2794
|
});
|
|
2702
2795
|
}
|
|
@@ -2718,21 +2811,21 @@ var OptimisticManager = class {
|
|
|
2718
2811
|
/**
|
|
2719
2812
|
* Toggle follow state
|
|
2720
2813
|
*/
|
|
2721
|
-
async toggleFollow(
|
|
2722
|
-
const
|
|
2723
|
-
if (!
|
|
2724
|
-
return
|
|
2814
|
+
async toggleFollow(itemId) {
|
|
2815
|
+
const item = this.feedManager?.getItem(itemId);
|
|
2816
|
+
if (!item) return false;
|
|
2817
|
+
return item.isFollowing ? this.unfollow(itemId) : this.follow(itemId);
|
|
2725
2818
|
}
|
|
2726
|
-
async follow(
|
|
2727
|
-
return this.performAction("follow",
|
|
2819
|
+
async follow(itemId) {
|
|
2820
|
+
return this.performAction("follow", itemId, async () => {
|
|
2728
2821
|
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2729
|
-
await this.interaction.follow(
|
|
2822
|
+
await this.interaction.follow(itemId);
|
|
2730
2823
|
});
|
|
2731
2824
|
}
|
|
2732
|
-
async unfollow(
|
|
2733
|
-
return this.performAction("unfollow",
|
|
2825
|
+
async unfollow(itemId) {
|
|
2826
|
+
return this.performAction("unfollow", itemId, async () => {
|
|
2734
2827
|
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2735
|
-
await this.interaction.unfollow(
|
|
2828
|
+
await this.interaction.unfollow(itemId);
|
|
2736
2829
|
});
|
|
2737
2830
|
}
|
|
2738
2831
|
addEventListener(listener) {
|
|
@@ -2754,10 +2847,10 @@ var OptimisticManager = class {
|
|
|
2754
2847
|
getPendingActions() {
|
|
2755
2848
|
return [...this.store.getState().pendingActions.values()];
|
|
2756
2849
|
}
|
|
2757
|
-
hasPendingAction(
|
|
2850
|
+
hasPendingAction(itemId, type) {
|
|
2758
2851
|
const actions = this.store.getState().pendingActions;
|
|
2759
2852
|
for (const action of actions.values()) {
|
|
2760
|
-
if (action.videoId ===
|
|
2853
|
+
if (action.videoId === itemId && action.status === "pending" && (!type || action.type === type)) {
|
|
2761
2854
|
return true;
|
|
2762
2855
|
}
|
|
2763
2856
|
}
|
|
@@ -2816,23 +2909,23 @@ var OptimisticManager = class {
|
|
|
2816
2909
|
}
|
|
2817
2910
|
}
|
|
2818
2911
|
}
|
|
2819
|
-
async performAction(type,
|
|
2820
|
-
const
|
|
2821
|
-
if (!
|
|
2822
|
-
if (this.hasPendingAction(
|
|
2912
|
+
async performAction(type, itemId, apiCall) {
|
|
2913
|
+
const item = this.feedManager?.getItem(itemId);
|
|
2914
|
+
if (!item) return false;
|
|
2915
|
+
if (this.hasPendingAction(itemId, type)) {
|
|
2823
2916
|
return false;
|
|
2824
2917
|
}
|
|
2825
2918
|
const action = {
|
|
2826
2919
|
id: generateActionId(),
|
|
2827
2920
|
type,
|
|
2828
|
-
videoId,
|
|
2829
|
-
rollbackData: this.createRollbackData(type,
|
|
2921
|
+
videoId: itemId,
|
|
2922
|
+
rollbackData: this.createRollbackData(type, item),
|
|
2830
2923
|
timestamp: Date.now(),
|
|
2831
2924
|
status: "pending",
|
|
2832
2925
|
retryCount: 0
|
|
2833
2926
|
};
|
|
2834
2927
|
this.addPendingAction(action);
|
|
2835
|
-
this.applyOptimisticUpdate(type,
|
|
2928
|
+
this.applyOptimisticUpdate(type, item);
|
|
2836
2929
|
this.emit({ type: "actionStart", action });
|
|
2837
2930
|
try {
|
|
2838
2931
|
await apiCall();
|
|
@@ -2846,32 +2939,32 @@ var OptimisticManager = class {
|
|
|
2846
2939
|
return false;
|
|
2847
2940
|
}
|
|
2848
2941
|
}
|
|
2849
|
-
createRollbackData(type,
|
|
2942
|
+
createRollbackData(type, item) {
|
|
2850
2943
|
if (type === "like" || type === "unlike") {
|
|
2851
|
-
return { isLiked:
|
|
2944
|
+
return { isLiked: item.isLiked, stats: { ...item.stats } };
|
|
2852
2945
|
}
|
|
2853
|
-
return { isFollowing:
|
|
2946
|
+
return { isFollowing: item.isFollowing };
|
|
2854
2947
|
}
|
|
2855
|
-
applyOptimisticUpdate(type,
|
|
2948
|
+
applyOptimisticUpdate(type, item) {
|
|
2856
2949
|
if (!this.feedManager) return;
|
|
2857
2950
|
if (type === "like") {
|
|
2858
|
-
this.feedManager.
|
|
2951
|
+
this.feedManager.updateItem(item.id, {
|
|
2859
2952
|
isLiked: true,
|
|
2860
|
-
stats: { ...
|
|
2953
|
+
stats: { ...item.stats, likes: item.stats.likes + 1 }
|
|
2861
2954
|
});
|
|
2862
2955
|
} else if (type === "unlike") {
|
|
2863
|
-
this.feedManager.
|
|
2956
|
+
this.feedManager.updateItem(item.id, {
|
|
2864
2957
|
isLiked: false,
|
|
2865
|
-
stats: { ...
|
|
2958
|
+
stats: { ...item.stats, likes: Math.max(0, item.stats.likes - 1) }
|
|
2866
2959
|
});
|
|
2867
2960
|
} else if (type === "follow") {
|
|
2868
|
-
this.feedManager.
|
|
2961
|
+
this.feedManager.updateItem(item.id, { isFollowing: true });
|
|
2869
2962
|
} else if (type === "unfollow") {
|
|
2870
|
-
this.feedManager.
|
|
2963
|
+
this.feedManager.updateItem(item.id, { isFollowing: false });
|
|
2871
2964
|
}
|
|
2872
2965
|
}
|
|
2873
2966
|
applyRollback(action) {
|
|
2874
|
-
this.feedManager?.
|
|
2967
|
+
this.feedManager?.updateItem(action.videoId, action.rollbackData);
|
|
2875
2968
|
this.emit({ type: "actionRollback", action });
|
|
2876
2969
|
}
|
|
2877
2970
|
addPendingAction(action) {
|
|
@@ -2929,9 +3022,9 @@ var OptimisticManager = class {
|
|
|
2929
3022
|
await this.interaction?.follow(updatedAction.videoId);
|
|
2930
3023
|
else if (updatedAction.type === "unfollow")
|
|
2931
3024
|
await this.interaction?.unfollow(updatedAction.videoId);
|
|
2932
|
-
const
|
|
2933
|
-
if (
|
|
2934
|
-
this.applyOptimisticUpdate(updatedAction.type,
|
|
3025
|
+
const currentItem = this.feedManager?.getItem(updatedAction.videoId);
|
|
3026
|
+
if (currentItem) {
|
|
3027
|
+
this.applyOptimisticUpdate(updatedAction.type, currentItem);
|
|
2935
3028
|
}
|
|
2936
3029
|
this.markActionSuccess(updatedAction.id);
|
|
2937
3030
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xhub-short/core",
|
|
3
3
|
"sideEffects": false,
|
|
4
|
-
"version": "0.1.0-beta.
|
|
4
|
+
"version": "0.1.0-beta.17",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"zustand": "^5.0.0",
|
|
24
|
-
"@xhub-short/contracts": "0.1.0-beta.
|
|
24
|
+
"@xhub-short/contracts": "0.1.0-beta.17"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"tsup": "^8.3.0",
|