diff --git a/src/screens/PhotosScreen/components/PhotosScrubber/index.tsx b/src/screens/PhotosScreen/components/PhotosScrubber/index.tsx new file mode 100644 index 000000000..c9c1ccabf --- /dev/null +++ b/src/screens/PhotosScreen/components/PhotosScrubber/index.tsx @@ -0,0 +1,213 @@ +import { CaretDownIcon, CaretUpIcon } from 'phosphor-react-native'; +import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react'; +import { Animated, StyleSheet, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Reanimated, { useAnimatedStyle } from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; +import AppText from 'src/components/AppText'; +import useGetColor from 'src/hooks/useColor'; +import { useTailwind } from 'tailwind-rn'; +import { + PhotosScrubberResult, + SCRUBBER_HANDLE_HIT_MARGIN, + SCRUBBER_HANDLE_SIZE, + SCRUBBER_RAIL_WIDTH, + SCRUBBER_YEAR_MARKER_HEIGHT, + ScrubberMonthLabelStore, +} from '../../hooks/usePhotosScrubber'; +import { getRailAnchorForScroll } from '../../utils/photoTimelineLayout'; + +interface PhotosScrubberProps { + scrubber: PhotosScrubberResult; +} + +const HANDLE_ROW_RIGHT_INSET = 4; +const PILL_HANDLE_GAP = 8; +const PILL_RIGHT_OFFSET = HANDLE_ROW_RIGHT_INSET + SCRUBBER_HANDLE_SIZE + PILL_HANDLE_GAP; + +const ScrubberMonthPill = ({ store }: { store: ScrubberMonthLabelStore }): JSX.Element | null => { + const tailwind = useTailwind(); + const getColor = useGetColor(); + const label = useSyncExternalStore(store.subscribe, store.getSnapshot); + + if (!label) { + return null; + } + + return ( + + + {label} + + + ); +}; + +const PhotosScrubber = ({ scrubber }: PhotosScrubberProps): JSX.Element | null => { + const tailwind = useTailwind(); + const getColor = useGetColor(); + + const { isAvailable, opacity, yearMarkersOpacity, railTop, railHeight, drag, yearMarkers, monthLabelStore } = + scrubber; + + const scrubberRef = useRef(scrubber); + scrubberRef.current = scrubber; + + const isDraggingHandleRef = useRef(false); + + const startScrub = useCallback(() => { + isDraggingHandleRef.current = true; + scrubberRef.current.onScrubStart(); + }, []); + + const moveScrub = useCallback((centerY: number) => { + scrubberRef.current.onScrubMove(centerY); + }, []); + + const endScrub = useCallback(() => { + if (!isDraggingHandleRef.current) { + return; + } + isDraggingHandleRef.current = false; + scrubberRef.current.onScrubEnd(); + }, []); + + const handleStyle = useAnimatedStyle(() => { + const anchor = drag.isDragging.value + ? drag.startCenterY.value + : getRailAnchorForScroll({ + scrollY: drag.scrollY.value, + maxScroll: drag.maxScroll.value, + railHeight: drag.railHeight.value, + }); + + return { transform: [{ translateY: anchor + drag.translateY.value - SCRUBBER_HANDLE_SIZE / 2 }] }; + }); + + // Rewrites the finger delta as part of the anchor, leaving the handle at the same coordinate. + const foldDragIntoAnchor = useCallback(() => { + 'worklet'; + drag.startCenterY.value = drag.startCenterY.value + drag.translateY.value; + drag.translateY.value = 0; + }, [drag]); + + const panGesture = useMemo( + () => + Gesture.Pan() + .enabled(isAvailable) + .maxPointers(1) + .hitSlop(SCRUBBER_HANDLE_HIT_MARGIN) + // Not .onStart(), which only fires past Pan's activation threshold — holding the handle + // without moving has to reveal the year markers. + .onTouchesDown(() => { + // Touch callbacks fire per pointer, so a second finger would otherwise re-pin the anchor + // mid-drag. + if (drag.isDragging.value) { + return; + } + // Not in startScrub: that arrives a beat later, and a first onUpdate would then clamp + // against the previous drag's anchor. + drag.startCenterY.value = getRailAnchorForScroll({ + scrollY: drag.scrollY.value, + maxScroll: drag.maxScroll.value, + railHeight: drag.railHeight.value, + }); + drag.translateY.value = 0; + drag.isDragging.value = true; + // Stops a previous release still settling from unpinning this drag. + drag.isReleasing.value = false; + scheduleOnRN(startScrub); + }) + .onUpdate((event) => { + // Clamped as an absolute centre, not as a delta, so the handle stops at the rail's ends + // while the finger keeps going. + const startCenterY = drag.startCenterY.value; + const centerY = Math.min(drag.railHeight.value, Math.max(0, startCenterY + event.translationY)); + drag.translateY.value = centerY - startCenterY; + scheduleOnRN(moveScrub, centerY); + }) + // A tap without movement never activates Pan, so onFinalize alone would not fire. + .onTouchesUp(() => { + foldDragIntoAnchor(); + scheduleOnRN(endScrub); + }) + // Backstop for cancellation paths. endScrub is idempotent, so both firing is fine. + .onFinalize(() => { + foldDragIntoAnchor(); + scheduleOnRN(endScrub); + }), + [isAvailable, drag, foldDragIntoAnchor, startScrub, moveScrub, endScrub], + ); + + if (!isAvailable) { + return null; + } + + return ( + + + {yearMarkers.map((marker) => ( + + + {marker.label} + + + ))} + + + + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + right: 0, + width: SCRUBBER_RAIL_WIDTH, + zIndex: 5, + }, + yearMarker: { + position: 'absolute', + right: PILL_RIGHT_OFFSET, + height: SCRUBBER_YEAR_MARKER_HEIGHT, + justifyContent: 'center', + paddingHorizontal: 10, + borderRadius: 12, + }, + handleRow: { + position: 'absolute', + right: HANDLE_ROW_RIGHT_INSET, + flexDirection: 'row', + alignItems: 'center', + }, + handle: { + width: SCRUBBER_HANDLE_SIZE, + height: SCRUBBER_HANDLE_SIZE, + borderRadius: SCRUBBER_HANDLE_SIZE / 2, + alignItems: 'center', + justifyContent: 'center', + }, + floatingPill: { + marginRight: PILL_HANDLE_GAP, + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 16, + minWidth: 96, + alignItems: 'center', + }, +}); + +export default PhotosScrubber; diff --git a/src/screens/PhotosScreen/components/PhotosTimeline.tsx b/src/screens/PhotosScreen/components/PhotosTimeline.tsx index be7c42b17..8e1718994 100644 --- a/src/screens/PhotosScreen/components/PhotosTimeline.tsx +++ b/src/screens/PhotosScreen/components/PhotosTimeline.tsx @@ -1,14 +1,16 @@ import { FlashList, FlashListRef, ListRenderItem } from '@shopify/flash-list'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import { Animated, Platform, StyleSheet, View } from 'react-native'; +import { Animated, LayoutChangeEvent, Platform, StyleSheet, View } from 'react-native'; import { GestureDetector } from 'react-native-gesture-handler'; import { useTailwind } from 'tailwind-rn'; import { useDragSelectGesture } from '../hooks/useDragSelectGesture'; +import { usePhotosScrubber } from '../hooks/usePhotosScrubber'; import { PhotoBackupState, PhotoDateGroup, TimelinePhotoItem } from '../types'; import { GroupBoundary, buildFlatTimeline, findGroupForIndex } from '../utils/photoTimelineGroups'; import PhotosGroupHeader, { GroupSyncStatus } from './GroupHeader/PhotosGroupHeader'; import PhotoItem from './PhotoItem'; import PhotosEmptyState from './PhotosEmptyState'; +import PhotosScrubber from './PhotosScrubber'; export interface PhotosTimelineHandle { scrollToAssetId: (id: string) => void; @@ -63,6 +65,14 @@ const HEADER_FADE_SCROLL_DISTANCE = 24; const FLOATING_HEADER_MIN_OPACITY = 0.02; const PULL_TO_REFRESH_FADE_START = -10; const PULL_TO_REFRESH_FADE_END = -60; +const LIST_PADDING_BOTTOM = 80; + +// Hoisted so the FlashList does not see a new object identity on every render. +const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 10 }; +const MAINTAIN_VISIBLE_CONTENT_POSITION = { disabled: true } as const; +const CONTENT_STYLE_EMPTY = { paddingBottom: LIST_PADDING_BOTTOM, flexGrow: 1 }; +const CONTENT_STYLE = { paddingTop: HEADER_HEIGHT, paddingBottom: LIST_PADDING_BOTTOM }; +const CONTENT_STYLE_REFRESHING = { paddingTop: 0, paddingBottom: LIST_PADDING_BOTTOM }; interface PhotosTimelineProps { assetsGroupsByDate: TimelineDateGroup[]; @@ -126,6 +136,23 @@ const PhotosTimeline = forwardRef( const boundariesRef = useRef(boundaries); boundariesRef.current = boundaries; + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const [listHeaderHeight, setListHeaderHeight] = useState(0); + + const onListHeaderLayout = useCallback( + (e: LayoutChangeEvent) => setListHeaderHeight(e.nativeEvent.layout.height), + [], + ); + + useEffect(() => { + if (!ListHeaderComponent) { + setListHeaderHeight(0); + } + }, [ListHeaderComponent]); + + const cellSize = containerSize.width / NUM_COLUMNS; + const contentTopInset = HEADER_HEIGHT + listHeaderHeight; + const scrollY = useRef(new Animated.Value(0)).current; // UIKit drops touches below alpha 0.01, so use FLOATING_HEADER_MIN_OPACITY as the floor so // pause/resume buttons are always touchable even when the floating layer is nearly invisible @@ -141,7 +168,12 @@ const PhotosTimeline = forwardRef( extrapolate: 'clamp', }); - const { gesture, onContainerLayout, onScroll, scrollOffsetRef } = useDragSelectGesture({ + const { + gesture, + onContainerLayout, + onScroll: onDragSelectScroll, + scrollOffsetRef, + } = useDragSelectGesture({ isSelectMode: !!isSelectMode, photos, scrollY, @@ -153,6 +185,40 @@ const PhotosTimeline = forwardRef( onDragEnd, }); + const scrollToOffset = useCallback((offset: number) => { + flashListRef.current?.scrollToOffset({ offset, animated: false }); + }, []); + + const scrubber = usePhotosScrubber({ + scrollY, + boundaries, + itemCount: photos.length, + cellSize, + contentTopInset, + containerHeight: containerSize.height, + numColumns: NUM_COLUMNS, + listPaddingBottom: LIST_PADDING_BOTTOM, + isEnabled: !isSelectMode, + scrollToOffset, + }); + + const onScroll = useCallback( + (e: Parameters[0]) => { + onDragSelectScroll(e); + scrubber.notifyScroll(); + }, + // notifyScroll specifically, not `scrubber`, which is a new object literal every render. + [onDragSelectScroll, scrubber.notifyScroll], + ); + + const onContainerLayoutMerged = useCallback( + (e: LayoutChangeEvent) => { + onContainerLayout(e); + setContainerSize({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height }); + }, + [onContainerLayout], + ); + const extraData = useMemo( () => ({ isSelectMode, selectedIds, onPausePress, onResumePress, onRetryPress }), [isSelectMode, selectedIds, onPausePress, onResumePress, onRetryPress], @@ -225,19 +291,37 @@ const PhotosTimeline = forwardRef( }, [isIosRefreshing]); const isEmpty = !isLoading && assetsGroupsByDate.length === 0; - const currentBoundary = boundaries.find((b) => b.id === topGroupId) ?? boundaries[0]; - const currentSyncStatus = currentBoundary?.syncStatus ?? { type: 'none' }; - const overlaySyncStatus = resolveOverlaySyncStatus({ - isSelectMode: !!isSelectMode, - selectedCount: selectedIds?.size ?? 0, - isAtListTop, - currentSyncStatus, - totalAssetsCount, - }); + + const currentBoundary = useMemo( + () => boundaries.find((b) => b.id === topGroupId) ?? boundaries[0], + [boundaries, topGroupId], + ); + + const overlaySyncStatus = useMemo( + () => + resolveOverlaySyncStatus({ + isSelectMode: !!isSelectMode, + selectedCount: selectedIds?.size ?? 0, + isAtListTop, + currentSyncStatus: currentBoundary?.syncStatus ?? { type: 'none' }, + totalAssetsCount, + }), + [isSelectMode, selectedIds, isAtListTop, currentBoundary, totalAssetsCount], + ); + + const contentContainerStyle = isEmpty + ? CONTENT_STYLE_EMPTY + : isIosRefreshing + ? CONTENT_STYLE_REFRESHING + : CONTENT_STYLE; + + const measuredListHeader = ListHeaderComponent ? ( + {ListHeaderComponent} + ) : undefined; return ( - + ( keyExtractor={keyExtractor} numColumns={NUM_COLUMNS} extraData={extraData} - ListHeaderComponent={ListHeaderComponent} + ListHeaderComponent={measuredListHeader} ListEmptyComponent={isEmpty ? : undefined} - contentContainerStyle={ - isEmpty - ? { paddingBottom: 80, flexGrow: 1 } - : { paddingTop: isIosRefreshing ? 0 : HEADER_HEIGHT, paddingBottom: 80 } - } + contentContainerStyle={contentContainerStyle} showsVerticalScrollIndicator={false} onEndReached={onEndReached} onEndReachedThreshold={0.5} refreshing={refreshing} onRefresh={onRefresh} onViewableItemsChanged={onViewableItemsChanged} - viewabilityConfig={{ itemVisiblePercentThreshold: 10 }} + viewabilityConfig={VIEWABILITY_CONFIG} onScroll={onScroll} scrollEventThrottle={16} progressViewOffset={Platform.OS === 'android' ? HEADER_HEIGHT : 0} - maintainVisibleContentPosition={{ disabled: true }} + maintainVisibleContentPosition={MAINTAIN_VISIBLE_CONTENT_POSITION} drawDistance={500} /> @@ -286,6 +366,8 @@ const PhotosTimeline = forwardRef( )} + + {!isEmpty && } ); diff --git a/src/screens/PhotosScreen/hooks/useCloudThumbnail.ts b/src/screens/PhotosScreen/hooks/useCloudThumbnail.ts index a4616543b..6f1e927a7 100644 --- a/src/screens/PhotosScreen/hooks/useCloudThumbnail.ts +++ b/src/screens/PhotosScreen/hooks/useCloudThumbnail.ts @@ -12,6 +12,11 @@ export const useCloudThumbnail = (item: CloudPhotoItem): { uri: string | null; o const userRef = useRef(user); userRef.current = user; + // The effect below also runs on retryCount, so id and path changes are tracked explicitly to tell + // a recycle or a backfill apart from a retry. + const lastItemIdRef = useRef(item.id); + const lastThumbnailPathPropRef = useRef(item.thumbnailPath); + const onImageError = useCallback(() => { photosLocalDB.setCloudThumbnailPath(item.id, null); setLocalPath(null); @@ -19,19 +24,26 @@ export const useCloudThumbnail = (item: CloudPhotoItem): { uri: string | null; o }, [item.id]); useEffect(() => { - // FlashList recycles cells: reset to the new item's persisted path before checking - if (retryCount === 0) { + const idChanged = lastItemIdRef.current !== item.id; + const persistedPathChanged = lastThumbnailPathPropRef.current !== item.thumbnailPath; + lastItemIdRef.current = item.id; + lastThumbnailPathPropRef.current = item.thumbnailPath; + + if (idChanged || persistedPathChanged) { setLocalPath(item.thumbnailPath); } + // retryCount belongs to the cell, which FlashList recycles across items, so it only counts as a + // retry for the item that actually failed — otherwise one failed image would make every later + // item in that cell refetch a thumbnail it already has on disk. + const isRetry = retryCount > 0 && !idChanged; + if (!isRetry && item.thumbnailPath) { + return; + } + const thumbnailBucketId = item.thumbnailBucketId; const thumbnailBucketFile = item.thumbnailBucketFile; const currentUser = userRef.current; - - // On first mount: skip if path already persisted. On retry (image load failed): always re-fetch. - if (retryCount === 0 && item.thumbnailPath) { - return; - } if (!thumbnailBucketId || !thumbnailBucketFile || !currentUser) { return; } diff --git a/src/screens/PhotosScreen/hooks/usePhotosScrubber.ts b/src/screens/PhotosScreen/hooks/usePhotosScrubber.ts new file mode 100644 index 000000000..d0205574b --- /dev/null +++ b/src/screens/PhotosScreen/hooks/usePhotosScrubber.ts @@ -0,0 +1,411 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Animated } from 'react-native'; +import { SharedValue, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; +import { GroupBoundary } from '../utils/photoTimelineGroups'; +import { + buildTimelineDateIndex, + findAnchorForIndex, + getIndexForOffset, + getOffsetForIndex, + getRailAnchorForScroll, + getTimelineContentHeight, +} from '../utils/photoTimelineLayout'; + +const SCRUBBER_IDLE_HIDE_MS = 1500; +const SCRUBBER_RELEASE_SETTLE_PX = 2; +const SCRUBBER_RELEASE_TIMEOUT_MS = 400; +const SCRUBBER_FADE_MS = 200; +const SCRUBBER_MIN_SCROLLABLE_SCREENS = 2; + +export const SCRUBBER_RAIL_WIDTH = 56; +export const SCRUBBER_HANDLE_SIZE = 44; +export const SCRUBBER_HANDLE_HIT_MARGIN = 12; +export const SCRUBBER_YEAR_MARKER_HEIGHT = 24; + +const SCRUBBER_RAIL_TOP_INSET = 72; +const SCRUBBER_RAIL_BOTTOM_INSET = 32; +const SCRUBBER_YEAR_LABEL_MIN_GAP = 28; + +export interface ScrubberYearMarker { + label: string; + y: number; +} + +/** Month label shown while dragging, shaped for `useSyncExternalStore`. Null when not dragging. */ +export interface ScrubberMonthLabelStore { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => string | null; +} + +interface WritableMonthLabelStore extends ScrubberMonthLabelStore { + set: (label: string | null) => void; +} + +const createMonthLabelStore = (): WritableMonthLabelStore => { + let label: string | null = null; + const listeners = new Set<() => void>(); + + return { + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot: () => label, + set: (next) => { + if (next === label) { + return; + } + label = next; + listeners.forEach((listener) => listener()); + }, + }; +}; + +interface PhotosScrubberConfig { + scrollY: Animated.Value; + boundaries: GroupBoundary[]; + itemCount: number; + cellSize: number; + contentTopInset: number; + containerHeight: number; + numColumns: number; + listPaddingBottom: number; + isEnabled: boolean; + scrollToOffset: (offset: number) => void; +} + +/** Handle position inputs, readable from both the gesture worklet and the animated style. */ +export interface ScrubberDragValues { + /** Rail length in pixels. The handle's centre is clamped to [0, railHeight]. */ + railHeight: SharedValue; + /** Maximum scroll offset, for mapping between the handle's position and the list's. */ + maxScroll: SharedValue; + /** Scroll position, mirrored from the RN Animated value FlashList drives. Positions the handle + * whenever a drag is not in progress. */ + scrollY: SharedValue; + /** Whether the handle is pinned to startCenterY rather than following the scroll. Stays true + * after the finger lifts until isReleasing resolves. */ + isDragging: SharedValue; + /** True between lifting the finger and the list reaching the offset the drag asked for. */ + isReleasing: SharedValue; + /** Where the handle's centre sits for the duration of a drag. */ + startCenterY: SharedValue; + /** Clamped finger displacement since the drag began. */ + translateY: SharedValue; +} + +export interface PhotosScrubberResult { + isAvailable: boolean; + opacity: Animated.Value; + yearMarkersOpacity: Animated.Value; + railTop: number; + railHeight: number; + drag: ScrubberDragValues; + yearMarkers: ScrubberYearMarker[]; + monthLabelStore: ScrubberMonthLabelStore; + onScrubStart: () => void; + /** @param centerY the handle centre's position along the rail (0..railHeight), already clamped. */ + onScrubMove: (centerY: number) => void; + onScrubEnd: () => void; + notifyScroll: () => void; +} + +const clampFraction = (value: number): number => Math.min(1, Math.max(0, value)); + +export const usePhotosScrubber = ({ + scrollY, + boundaries, + itemCount, + cellSize, + contentTopInset, + containerHeight, + numColumns, + listPaddingBottom, + isEnabled, + scrollToOffset, +}: PhotosScrubberConfig): PhotosScrubberResult => { + const [isVisible, setIsVisible] = useState(false); + const [isScrubbing, setIsScrubbing] = useState(false); + + const isScrubbingRef = useRef(isScrubbing); + isScrubbingRef.current = isScrubbing; + + const railHeight = Math.max(0, containerHeight - SCRUBBER_RAIL_TOP_INSET - SCRUBBER_RAIL_BOTTOM_INSET); + + const contentHeight = getTimelineContentHeight({ + itemCount, + cellSize, + contentTopInset, + paddingBottom: listPaddingBottom, + numColumns, + }); + const maxScroll = Math.max(1, contentHeight - containerHeight); + + const isAvailable = + isEnabled && + itemCount > 0 && + railHeight > 0 && + cellSize > 0 && + contentHeight > containerHeight * SCRUBBER_MIN_SCROLLABLE_SCREENS; + + const isAvailableRef = useRef(isAvailable); + isAvailableRef.current = isAvailable; + + const scrubMetricsRef = useRef({ railHeight, maxScroll, cellSize, contentTopInset, numColumns }); + scrubMetricsRef.current = { railHeight, maxScroll, cellSize, contentTopInset, numColumns }; + + const scrollToOffsetRef = useRef(scrollToOffset); + scrollToOffsetRef.current = scrollToOffset; + + const dragTranslateY = useSharedValue(0); + const dragStartCenterY = useSharedValue(0); + const dragIsDragging = useSharedValue(false); + const dragIsReleasing = useSharedValue(false); + const railHeightShared = useSharedValue(0); + const maxScrollShared = useSharedValue(0); + const scrollYShared = useSharedValue(0); + + const drag = useMemo( + () => ({ + railHeight: railHeightShared, + maxScroll: maxScrollShared, + scrollY: scrollYShared, + isDragging: dragIsDragging, + isReleasing: dragIsReleasing, + startCenterY: dragStartCenterY, + translateY: dragTranslateY, + }), + [ + railHeightShared, + maxScrollShared, + scrollYShared, + dragIsDragging, + dragIsReleasing, + dragStartCenterY, + dragTranslateY, + ], + ); + + useEffect(() => { + railHeightShared.value = railHeight; + maxScrollShared.value = maxScroll; + }, [railHeight, maxScroll, railHeightShared, maxScrollShared]); + + useEffect(() => { + const id = scrollY.addListener(({ value }) => { + scrollYShared.value = value; + }); + return () => scrollY.removeListener(id); + }, [scrollY, scrollYShared]); + + const { yearAnchors, monthAnchors } = useMemo(() => buildTimelineDateIndex(boundaries), [boundaries]); + + const monthAnchorsRef = useRef(monthAnchors); + monthAnchorsRef.current = monthAnchors; + + const yearMarkers = useMemo(() => { + const markers: ScrubberYearMarker[] = []; + let lastKeptY = Number.NEGATIVE_INFINITY; + + for (const anchor of yearAnchors) { + const offset = getOffsetForIndex({ index: anchor.startIndex, cellSize, contentTopInset, numColumns }); + const y = clampFraction(offset / maxScroll) * railHeight; + if (y - lastKeptY < SCRUBBER_YEAR_LABEL_MIN_GAP) { + continue; + } + markers.push({ label: anchor.label, y }); + lastKeptY = y; + } + + return markers; + }, [yearAnchors, cellSize, contentTopInset, numColumns, maxScroll, railHeight]); + + const monthLabelStoreRef = useRef(null); + if (monthLabelStoreRef.current === null) { + monthLabelStoreRef.current = createMonthLabelStore(); + } + const monthLabelStore = monthLabelStoreRef.current; + + const updateMonthLabelForOffset = useCallback( + (offset: number) => { + const { cellSize: rowHeight, contentTopInset: topInset, numColumns: columns } = scrubMetricsRef.current; + const index = getIndexForOffset({ offset, cellSize: rowHeight, contentTopInset: topInset, numColumns: columns }); + const anchor = findAnchorForIndex(monthAnchorsRef.current, index); + monthLabelStore.set(anchor?.label ?? null); + }, + [monthLabelStore], + ); + + const opacity = useRef(new Animated.Value(0)).current; + const yearMarkersOpacity = useRef(new Animated.Value(0)).current; + + const hideTimeoutRef = useRef | null>(null); + const lastScrollAtRef = useRef(0); + + const clearHideTimeout = useCallback(() => { + if (hideTimeoutRef.current !== null) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + }, []); + + const runHideTickRef = useRef<() => void>(null!); + runHideTickRef.current = () => { + hideTimeoutRef.current = null; + if (isScrubbingRef.current) { + return; + } + const idleFor = Date.now() - lastScrollAtRef.current; + if (idleFor < SCRUBBER_IDLE_HIDE_MS) { + hideTimeoutRef.current = setTimeout(() => runHideTickRef.current(), SCRUBBER_IDLE_HIDE_MS - idleFor); + return; + } + setIsVisible(false); + }; + + // The pending timer re-arms itself with the idle time left, so skipping here loses nothing. + const armHideTimer = useCallback((delay: number) => { + if (hideTimeoutRef.current !== null) { + return; + } + hideTimeoutRef.current = setTimeout(() => runHideTickRef.current(), delay); + }, []); + + const notifyScroll = useCallback(() => { + if (!isAvailableRef.current || isScrubbingRef.current) { + return; + } + lastScrollAtRef.current = Date.now(); + setIsVisible(true); + armHideTimer(SCRUBBER_IDLE_HIDE_MS); + }, [armHideTimer]); + + const releaseTimeoutRef = useRef | null>(null); + + const clearReleaseTimeout = useCallback(() => { + if (releaseTimeoutRef.current !== null) { + clearTimeout(releaseTimeoutRef.current); + releaseTimeoutRef.current = null; + } + }, []); + + // Hands the handle back to the scroll only once the scroll agrees with where it is being held. + useAnimatedReaction( + () => + getRailAnchorForScroll({ + scrollY: scrollYShared.value, + maxScroll: maxScrollShared.value, + railHeight: railHeightShared.value, + }), + (scrollAnchor) => { + if (!dragIsReleasing.value) { + return; + } + if (Math.abs(scrollAnchor - dragStartCenterY.value) <= SCRUBBER_RELEASE_SETTLE_PX) { + dragIsReleasing.value = false; + dragIsDragging.value = false; + } + }, + ); + + const onScrubStart = useCallback(() => { + // A release still settling from the previous drag would unpin this one mid-drag. + clearReleaseTimeout(); + clearHideTimeout(); + setIsVisible(true); + setIsScrubbing(true); + }, [clearHideTimeout, clearReleaseTimeout]); + + const onScrubMove = useCallback( + (centerY: number) => { + const { railHeight: currentRailHeight, maxScroll: currentMaxScroll } = scrubMetricsRef.current; + if (currentRailHeight <= 0) { + return; + } + const offset = clampFraction(centerY / currentRailHeight) * currentMaxScroll; + scrollToOffsetRef.current(offset); + updateMonthLabelForOffset(offset); + }, + [updateMonthLabelForOffset], + ); + + const onScrubEnd = useCallback(() => { + // The anchor stays pinned past the end of the drag until the list catches up; the reaction + // above unpins it on arrival, the timeout below unpins it if it never arrives. + dragIsReleasing.value = true; + clearReleaseTimeout(); + releaseTimeoutRef.current = setTimeout(() => { + releaseTimeoutRef.current = null; + // A new drag clears isReleasing from its worklet, which lands before onScrubStart gets to + // cancel this timer. + if (!dragIsReleasing.value) { + return; + } + dragIsReleasing.value = false; + dragIsDragging.value = false; + }, SCRUBBER_RELEASE_TIMEOUT_MS); + + setIsScrubbing(false); + monthLabelStore.set(null); + lastScrollAtRef.current = Date.now(); + armHideTimer(SCRUBBER_IDLE_HIDE_MS); + }, [armHideTimer, monthLabelStore, dragIsDragging, dragIsReleasing, clearReleaseTimeout]); + + useEffect(() => { + Animated.timing(opacity, { + toValue: isVisible && isAvailable ? 1 : 0, + duration: SCRUBBER_FADE_MS, + useNativeDriver: false, + }).start(); + }, [isVisible, isAvailable, opacity]); + + useEffect(() => { + Animated.timing(yearMarkersOpacity, { + toValue: isScrubbing ? 1 : 0, + duration: SCRUBBER_FADE_MS, + useNativeDriver: true, + }).start(); + }, [isScrubbing, yearMarkersOpacity]); + + useEffect(() => { + if (isAvailable) { + return; + } + clearHideTimeout(); + clearReleaseTimeout(); + dragIsDragging.value = false; + dragIsReleasing.value = false; + dragTranslateY.value = 0; + monthLabelStore.set(null); + setIsScrubbing(false); + setIsVisible(false); + }, [ + isAvailable, + clearHideTimeout, + clearReleaseTimeout, + dragIsDragging, + dragIsReleasing, + dragTranslateY, + monthLabelStore, + ]); + + useEffect(() => clearHideTimeout, [clearHideTimeout]); + useEffect(() => clearReleaseTimeout, [clearReleaseTimeout]); + + return { + isAvailable, + opacity, + yearMarkersOpacity, + railTop: SCRUBBER_RAIL_TOP_INSET, + railHeight, + drag, + yearMarkers, + monthLabelStore, + onScrubStart, + onScrubMove, + onScrubEnd, + notifyScroll, + }; +}; diff --git a/src/screens/PhotosScreen/utils/photoTimelineGroups.ts b/src/screens/PhotosScreen/utils/photoTimelineGroups.ts index d47168137..d2ad304f9 100644 --- a/src/screens/PhotosScreen/utils/photoTimelineGroups.ts +++ b/src/screens/PhotosScreen/utils/photoTimelineGroups.ts @@ -242,14 +242,30 @@ export const buildFlatTimeline = (groups: TimelineDateGroup[]): FlatTimeline => return { photos, boundaries }; }; -export const findGroupForIndex = (boundaries: GroupBoundary[], itemIndex: number): GroupBoundary | undefined => { - let result: GroupBoundary | undefined; - for (const boundary of boundaries) { - if (boundary.startIndex <= itemIndex) { - result = boundary; +/** + * Last item at or before the given index, or undefined when the index precedes every item. Runs a + * binary search. + * + * @param items items ordered by `startIndex` ascending. + * @param index item index in the flat timeline. + */ +export const findLastAtOrBefore = (items: T[], index: number): T | undefined => { + let low = 0; + let high = items.length - 1; + let result: T | undefined; + + while (low <= high) { + const middle = (low + high) >> 1; + if (items[middle].startIndex <= index) { + result = items[middle]; + low = middle + 1; } else { - break; + high = middle - 1; } } + return result; }; + +export const findGroupForIndex = (boundaries: GroupBoundary[], itemIndex: number): GroupBoundary | undefined => + findLastAtOrBefore(boundaries, itemIndex); diff --git a/src/screens/PhotosScreen/utils/photoTimelineLayout.spec.ts b/src/screens/PhotosScreen/utils/photoTimelineLayout.spec.ts new file mode 100644 index 000000000..833f1cc87 --- /dev/null +++ b/src/screens/PhotosScreen/utils/photoTimelineLayout.spec.ts @@ -0,0 +1,166 @@ +import { GroupBoundary } from './photoTimelineGroups'; +import { + buildTimelineDateIndex, + findAnchorForIndex, + getIndexForOffset, + getOffsetForIndex, + getRailAnchorForScroll, + getTimelineContentHeight, +} from './photoTimelineLayout'; + +const makeBoundary = (overrides: Partial = {}): GroupBoundary => ({ + startIndex: 0, + id: new Date(2024, 0, 1).toDateString(), + label: '1 Jan 2024', + syncStatus: { type: 'none' }, + ...overrides, +}); + +describe('building the year and month anchors of the timeline', () => { + test('when the timeline spans several years, then one anchor is produced per year', () => { + const boundaries: GroupBoundary[] = [ + makeBoundary({ startIndex: 0, id: new Date(2026, 7, 1).toDateString() }), + makeBoundary({ startIndex: 30, id: new Date(2025, 5, 1).toDateString() }), + makeBoundary({ startIndex: 90, id: new Date(2024, 2, 1).toDateString() }), + ]; + + const { yearAnchors } = buildTimelineDateIndex(boundaries); + + expect(yearAnchors.map((a) => a.label)).toEqual(['2026', '2025', '2024']); + expect(yearAnchors.map((a) => a.startIndex)).toEqual([0, 30, 90]); + }); + + test('when a year has photos in more than one month, then the year anchor lands on its oldest boundary', () => { + const boundaries: GroupBoundary[] = [ + makeBoundary({ startIndex: 0, id: new Date(2023, 11, 20).toDateString() }), + makeBoundary({ startIndex: 12, id: new Date(2023, 5, 10).toDateString() }), + makeBoundary({ startIndex: 24, id: new Date(2023, 0, 5).toDateString() }), + makeBoundary({ startIndex: 36, id: new Date(2022, 11, 30).toDateString() }), + ]; + + const { yearAnchors } = buildTimelineDateIndex(boundaries); + + expect(yearAnchors.map((a) => a.label)).toEqual(['2023', '2022']); + expect(yearAnchors.map((a) => a.startIndex)).toEqual([24, 36]); + }); + + test('when a month has photos in more than one day, then only the first day becomes the month anchor', () => { + const boundaries: GroupBoundary[] = [ + makeBoundary({ startIndex: 0, id: new Date(2024, 5, 20).toDateString() }), + makeBoundary({ startIndex: 12, id: new Date(2024, 5, 10).toDateString() }), + makeBoundary({ startIndex: 24, id: new Date(2024, 5, 1).toDateString() }), + ]; + + const { monthAnchors } = buildTimelineDateIndex(boundaries); + + expect(monthAnchors).toHaveLength(1); + expect(monthAnchors[0].startIndex).toBe(0); + }); + + test('when the skeleton group is present, then it is not turned into an anchor', () => { + const boundaries: GroupBoundary[] = [ + makeBoundary({ startIndex: 0, id: new Date(2024, 5, 1).toDateString() }), + makeBoundary({ startIndex: 12, id: '__skeleton__', label: '' }), + ]; + + const { yearAnchors, monthAnchors } = buildTimelineDateIndex(boundaries); + + expect(yearAnchors).toHaveLength(1); + expect(monthAnchors).toHaveLength(1); + }); + + test('when the timeline is empty, then no anchors are produced', () => { + const { yearAnchors, monthAnchors } = buildTimelineDateIndex([]); + + expect(yearAnchors).toEqual([]); + expect(monthAnchors).toEqual([]); + }); +}); + +describe('converting between an item position and a scroll offset', () => { + test('when an item index is given, then its pixel offset accounts for the header inset and the row height', () => { + const offset = getOffsetForIndex({ index: 7, cellSize: 120, contentTopInset: 64, numColumns: 3 }); + + // row = floor(7 / 3) = 2 + expect(offset).toBe(64 + 2 * 120); + }); + + test('when a scroll offset falls inside a month, then that month is the one reported', () => { + const boundaries: GroupBoundary[] = [ + makeBoundary({ startIndex: 0, id: new Date(2024, 7, 15).toDateString() }), + makeBoundary({ startIndex: 30, id: new Date(2024, 6, 10).toDateString() }), + makeBoundary({ startIndex: 60, id: new Date(2024, 5, 1).toDateString() }), + ]; + const { monthAnchors } = buildTimelineDateIndex(boundaries); + + const cellSize = 120; + const contentTopInset = 64; + const numColumns = 3; + + // Offset that lands inside the second month's range (index 30..59) + const offset = getOffsetForIndex({ index: 40, cellSize, contentTopInset, numColumns }); + const index = getIndexForOffset({ offset, cellSize, contentTopInset, numColumns }); + const anchor = findAnchorForIndex(monthAnchors, index); + + expect(anchor?.startIndex).toBe(30); + }); +}); + +describe('placing the scrubber handle for a scroll position', () => { + test('when the list is scrolled halfway, then the handle sits halfway down the rail', () => { + expect(getRailAnchorForScroll({ scrollY: 500, maxScroll: 1000, railHeight: 400 })).toBe(200); + }); + + test('when the list is at the very top, then the handle sits at the top of the rail', () => { + expect(getRailAnchorForScroll({ scrollY: 0, maxScroll: 1000, railHeight: 400 })).toBe(0); + }); + + test('when the list is scrolled past its end, then the handle stops at the bottom of the rail', () => { + expect(getRailAnchorForScroll({ scrollY: 1800, maxScroll: 1000, railHeight: 400 })).toBe(400); + }); + + test('when the list is overscrolled above the top, then the handle stops at the top of the rail', () => { + expect(getRailAnchorForScroll({ scrollY: -120, maxScroll: 1000, railHeight: 400 })).toBe(0); + }); + + test('when there is nothing to scroll, then the handle sits at the top of the rail', () => { + expect(getRailAnchorForScroll({ scrollY: 300, maxScroll: 0, railHeight: 400 })).toBe(0); + }); +}); + +describe('finding the anchor that owns a given item position', () => { + test('when the index precedes every anchor, then no anchor is returned', () => { + const anchors = [{ startIndex: 10, label: 'a' }]; + + expect(findAnchorForIndex(anchors, 5)).toBeUndefined(); + }); + + test('when the index matches the last anchor exactly, then that anchor is returned', () => { + const anchors = [ + { startIndex: 0, label: 'a' }, + { startIndex: 10, label: 'b' }, + { startIndex: 20, label: 'c' }, + ]; + + expect(findAnchorForIndex(anchors, 20)?.label).toBe('c'); + expect(findAnchorForIndex(anchors, 25)?.label).toBe('c'); + expect(findAnchorForIndex(anchors, 15)?.label).toBe('b'); + }); +}); + +describe('measuring the total height of the timeline', () => { + test('when the content is shorter than the viewport, then the scroll range never collapses to zero', () => { + const contentHeight = getTimelineContentHeight({ + itemCount: 3, + cellSize: 120, + contentTopInset: 64, + paddingBottom: 80, + numColumns: 3, + }); + + // 1 row of 3 items + header inset + bottom padding + expect(contentHeight).toBe(64 + 120 + 80); + const maxScroll = Math.max(1, contentHeight - 900); + expect(maxScroll).toBe(1); + }); +}); diff --git a/src/screens/PhotosScreen/utils/photoTimelineLayout.ts b/src/screens/PhotosScreen/utils/photoTimelineLayout.ts new file mode 100644 index 000000000..37f8ee551 --- /dev/null +++ b/src/screens/PhotosScreen/utils/photoTimelineLayout.ts @@ -0,0 +1,154 @@ +import { GroupBoundary, findLastAtOrBefore } from './photoTimelineGroups'; + +const SKELETON_GROUP_ID = '__skeleton__'; + +export interface TimelineAnchor { + startIndex: number; + label: string; +} + +export interface TimelineDateIndex { + yearAnchors: TimelineAnchor[]; + monthAnchors: TimelineAnchor[]; +} + +export interface TimelineLayoutMetrics { + cellSize: number; + contentTopInset: number; + numColumns: number; +} + +const formatMonthLabel = (date: Date): string => date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + +/** + * Derives the year and month anchors used to position the scrubber rail. + * + * Year anchors sit on the LAST (oldest) boundary of each year; month anchors sit on the FIRST + * (newest) boundary of each month instead. Both results are ordered by `startIndex` ascending and + * can be binary searched. + * + * @param boundaries day boundaries of the flat timeline, ordered by `startIndex` ascending. + */ +export const buildTimelineDateIndex = (boundaries: GroupBoundary[]): TimelineDateIndex => { + const validEntries = boundaries.reduce>((entries, boundary) => { + if (boundary.id === SKELETON_GROUP_ID) { + return entries; + } + const date = new Date(boundary.id); + if (Number.isNaN(date.getTime())) { + return entries; + } + entries.push({ boundary, date }); + return entries; + }, []); + + const yearAnchors: TimelineAnchor[] = []; + const monthAnchors: TimelineAnchor[] = []; + let lastMonthKey: string | null = null; + + for (let i = 0; i < validEntries.length; i++) { + const { boundary, date } = validEntries[i]; + const year = date.getFullYear(); + const monthKey = `${year}-${date.getMonth()}`; + + const nextYear = validEntries[i + 1]?.date.getFullYear() ?? null; + if (year !== nextYear) { + yearAnchors.push({ startIndex: boundary.startIndex, label: `${year}` }); + } + + if (monthKey !== lastMonthKey) { + monthAnchors.push({ startIndex: boundary.startIndex, label: formatMonthLabel(date) }); + lastMonthKey = monthKey; + } + } + + return { yearAnchors, monthAnchors }; +}; + +/** + * Scroll offset in pixels at which the row containing the given item starts. + * + * @param params.index item index in the flat timeline. + * @param params.cellSize height of a grid row in pixels. + * @param params.contentTopInset pixels above the first row (list top padding plus list header). + * @param params.numColumns columns in the grid. + */ +export const getOffsetForIndex = ({ + index, + cellSize, + contentTopInset, + numColumns, +}: TimelineLayoutMetrics & { index: number }): number => contentTopInset + Math.floor(index / numColumns) * cellSize; + +/** + * Index of the first item on the row visible at the given scroll offset. Inverse of + * {@link getOffsetForIndex}. + * + * @param params.offset scroll offset in pixels. + * @param params.cellSize height of a grid row in pixels. + * @param params.contentTopInset pixels above the first row (list top padding plus list header). + * @param params.numColumns columns in the grid. + */ +export const getIndexForOffset = ({ + offset, + cellSize, + contentTopInset, + numColumns, +}: TimelineLayoutMetrics & { offset: number }): number => { + if (cellSize <= 0) { + return 0; + } + const row = Math.floor((offset - contentTopInset) / cellSize); + return Math.max(0, row) * numColumns; +}; + +/** + * Position along the scrubber rail, in pixels from its top, matching a scroll offset. Clamped to + * [0, railHeight]. Callable from a worklet and from the JS thread. + * + * @param params.scrollY scroll offset in pixels. + * @param params.maxScroll maximum scroll offset in pixels. + * @param params.railHeight rail length in pixels. + */ +export const getRailAnchorForScroll = ({ + scrollY, + maxScroll, + railHeight, +}: { + scrollY: number; + maxScroll: number; + railHeight: number; +}): number => { + 'worklet'; + const fraction = maxScroll > 0 ? Math.min(1, Math.max(0, scrollY / maxScroll)) : 0; + return fraction * railHeight; +}; + +/** + * Total scrollable content height of the timeline, derived from the item count instead of measured, + * so it is available on the first render. + * + * @param params.itemCount items in the flat timeline. + * @param params.cellSize height of a grid row in pixels. + * @param params.contentTopInset pixels above the first row (list top padding plus list header). + * @param params.paddingBottom list bottom padding in pixels. + * @param params.numColumns columns in the grid. + */ +export const getTimelineContentHeight = ({ + itemCount, + cellSize, + contentTopInset, + paddingBottom, + numColumns, +}: TimelineLayoutMetrics & { itemCount: number; paddingBottom: number }): number => + contentTopInset + Math.ceil(itemCount / numColumns) * cellSize + paddingBottom; + +/** + * Last anchor starting at or before the given item index, or undefined when the index precedes + * every anchor. Runs a binary search. + * + * @param anchors anchors ordered by `startIndex` ascending. + * @param index item index in the flat timeline. + */ +export const findAnchorForIndex = (anchors: TimelineAnchor[], index: number): TimelineAnchor | undefined => + findLastAtOrBefore(anchors, index);