From c88aa2056b35b03372f558aa8c9984e6988cd5ef Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Sat, 27 Dec 2025 18:48:17 -0800 Subject: [PATCH] Simplified edit feeds UI --- src/screens/SavedFeeds.tsx | 376 ++++++++++++++++++-------- src/view/com/feeds/FeedSourceCard.tsx | 4 +- 2 files changed, 263 insertions(+), 117 deletions(-) diff --git a/src/screens/SavedFeeds.tsx b/src/screens/SavedFeeds.tsx index 1e8eab5953..a987a38db7 100644 --- a/src/screens/SavedFeeds.tsx +++ b/src/screens/SavedFeeds.tsx @@ -1,6 +1,12 @@ -import {useCallback, useState} from 'react' +import {useCallback, useMemo, useState} from 'react' import {View} from 'react-native' -import Animated, {LinearTransition} from 'react-native-reanimated' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated' import {type AppBskyActorDefs} from '@atproto/api' import {TID} from '@atproto/common-web' import {msg, Trans} from '@lingui/macro' @@ -29,12 +35,9 @@ import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import { - ArrowBottom_Stroke2_Corner0_Rounded as ArrowDownIcon, - ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon, -} from '#/components/icons/Arrow' import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk' +import {Menu_Stroke2_Corner0_Rounded as DragHandleIcon} from '#/components/icons/Menu' import {Pin_Filled_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import * as Layout from '#/components/Layout' @@ -78,6 +81,11 @@ function SavedFeedsInner({ const noFollowingFeed = currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType + const scrollGesture = Gesture.Native() + const draggedItemId = useSharedValue(null) + const draggedFromIndex = useSharedValue(-1) + const draggedToIndex = useSharedValue(-1) + useFocusEffect( useCallback(() => { setMinimalShellMode(false) @@ -122,7 +130,7 @@ function SavedFeedsInner({ - + {noSavedFeedsOfAnyType && ( )) ) @@ -199,7 +210,10 @@ function SavedFeedsInner({ isPinned={false} currentFeeds={currentFeeds} setCurrentFeeds={setCurrentFeeds} - preferences={preferences} + scrollGesture={scrollGesture} + draggedItemId={draggedItemId} + draggedFromIndex={draggedFromIndex} + draggedToIndex={draggedToIndex} /> )) ) @@ -236,18 +250,198 @@ function ListItem({ isPinned, currentFeeds, setCurrentFeeds, + scrollGesture, + draggedItemId, + draggedFromIndex, + draggedToIndex, }: { feed: AppBskyActorDefs.SavedFeed isPinned: boolean currentFeeds: AppBskyActorDefs.SavedFeed[] setCurrentFeeds: React.Dispatch - preferences: UsePreferencesQueryResponse + scrollGesture?: any + draggedItemId?: any + draggedFromIndex?: any + draggedToIndex?: any }) { const {_} = useLingui() const t = useTheme() const playHaptic = useHaptics() const feedUri = feed.value + const [itemHeight, setItemHeight] = useState(0) + const translateY = useSharedValue(0) + const isDragging = useSharedValue(false) + const lastTentativeIndex = useSharedValue(-1) + + const reorderFeeds = useCallback( + (fromIndex: number, toIndex: number) => { + const nextFeeds = currentFeeds.slice() + const [movedItem] = nextFeeds.splice(fromIndex, 1) + nextFeeds.splice(toIndex, 0, movedItem) + setCurrentFeeds(nextFeeds) + }, + [currentFeeds, setCurrentFeeds], + ) + + const panGesture = useMemo(() => { + if (!isPinned || !scrollGesture || !draggedItemId) return Gesture.Native() + + return Gesture.Pan() + .blocksExternalGesture(scrollGesture) + .activeOffsetY([-5, 5]) + .failOffsetX([-20, 20]) + .onStart(() => { + 'worklet' + isDragging.set(true) + const pinnedFeeds = currentFeeds.filter(f => f.pinned) + const currentIndex = pinnedFeeds.findIndex(f => f.id === feed.id) + draggedItemId.set(feed.id) + draggedFromIndex.set(currentIndex) + draggedToIndex.set(currentIndex) + runOnJS(playHaptic)() + }) + .onUpdate(evt => { + 'worklet' + if (itemHeight === 0) return + + const newTranslateY = evt.translationY + const positionOffset = Math.round(newTranslateY / itemHeight) + const currentIndex = currentFeeds + .filter(f => f.pinned) + .findIndex(f => f.id === feed.id) + const pinnedCount = currentFeeds.filter(f => f.pinned).length + const tentativeIndex = Math.max( + 0, + Math.min(currentIndex + positionOffset, pinnedCount - 1), + ) + + if (tentativeIndex !== lastTentativeIndex.get()) { + runOnJS(playHaptic)() + lastTentativeIndex.set(tentativeIndex) + draggedToIndex.set(tentativeIndex) + } + + translateY.set(newTranslateY) + }) + .onEnd(evt => { + 'worklet' + if (itemHeight === 0) { + translateY.set(withSpring(0)) + isDragging.set(false) + draggedItemId.set(null) + draggedFromIndex.set(-1) + draggedToIndex.set(-1) + return + } + + const positionOffset = Math.round(evt.translationY / itemHeight) + const pinnedFeeds = currentFeeds.filter(f => f.pinned) + const currentIndex = pinnedFeeds.findIndex(f => f.id === feed.id) + const newIndex = Math.max( + 0, + Math.min(currentIndex + positionOffset, pinnedFeeds.length - 1), + ) + + translateY.set( + withSpring(0, { + mass: 1.25, + damping: 300, + stiffness: 800, + }), + ) + isDragging.set(false) + lastTentativeIndex.set(-1) + draggedItemId.set(null) + draggedFromIndex.set(-1) + draggedToIndex.set(-1) + + if (newIndex !== currentIndex && currentIndex !== -1) { + runOnJS(reorderFeeds)(currentIndex, newIndex) + } + }) + }, [ + isPinned, + scrollGesture, + draggedItemId, + draggedFromIndex, + draggedToIndex, + itemHeight, + currentFeeds, + feed.id, + playHaptic, + reorderFeeds, + isDragging, + lastTentativeIndex, + translateY, + ]) + + const animatedStyle = useAnimatedStyle(() => { + if (!draggedItemId || !isPinned) { + return { + transform: [{translateY: 0}], + opacity: 1, + zIndex: 0, + } + } + + const isBeingDragged = draggedItemId.get() === feed.id + + if (isBeingDragged) { + // This is the item being dragged + return { + transform: [ + {translateY: translateY.get()}, + {scale: isDragging.get() ? 1.02 : 1}, + ], + opacity: isDragging.get() ? 0.8 : 1, + zIndex: isDragging.get() ? 10 : 0, + } + } + + // This is a non-dragged item - calculate if it should shift + const pinnedFeeds = currentFeeds.filter(f => f.pinned) + const myIndex = pinnedFeeds.findIndex(f => f.id === feed.id) + const fromIndex = draggedFromIndex.get() + const toIndex = draggedToIndex.get() + + if (myIndex === -1 || fromIndex === -1 || toIndex === -1) { + return { + transform: [{translateY: 0}], + opacity: 1, + zIndex: 0, + } + } + + let offset = 0 + + if (fromIndex < toIndex) { + // Dragging downward + if (myIndex > fromIndex && myIndex <= toIndex) { + offset = -itemHeight // Shift up + } + } else if (fromIndex > toIndex) { + // Dragging upward + if (myIndex < fromIndex && myIndex >= toIndex) { + offset = itemHeight // Shift down + } + } + + return { + transform: [ + { + translateY: withSpring(offset, { + mass: 1.25, + damping: 300, + stiffness: 800, + }), + }, + ], + opacity: 1, + zIndex: 0, + } + }, [draggedItemId, isPinned, feed.id, currentFeeds, itemHeight]) + const onTogglePinned = async () => { playHaptic() setCurrentFeeds( @@ -257,123 +451,75 @@ function ListItem({ ) } - const onPressUp = async () => { - if (!isPinned) return - - const nextFeeds = currentFeeds.slice() - const ids = currentFeeds.map(f => f.id) - const index = ids.indexOf(feed.id) - const nextIndex = index - 1 - - if (index === -1 || index === 0) return - ;[nextFeeds[index], nextFeeds[nextIndex]] = [ - nextFeeds[nextIndex], - nextFeeds[index], - ] - - setCurrentFeeds(nextFeeds) - } - - const onPressDown = async () => { - if (!isPinned) return - - const nextFeeds = currentFeeds.slice() - const ids = currentFeeds.map(f => f.id) - const index = ids.indexOf(feed.id) - const nextIndex = index + 1 - - if (index === -1 || index >= nextFeeds.filter(f => f.pinned).length - 1) - return - ;[nextFeeds[index], nextFeeds[nextIndex]] = [ - nextFeeds[nextIndex], - nextFeeds[index], - ] - - setCurrentFeeds(nextFeeds) - } - const onPressRemove = async () => { playHaptic() setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) } return ( - - {feed.type === 'timeline' ? ( - - ) : ( - - )} - - {isPinned ? ( - <> - - - - ) : ( - + + { + if (itemHeight === 0) { + setItemHeight(e.nativeEvent.layout.height) + } + }}> + {/* Drag Handle - only for pinned feeds */} + {isPinned && ( + + + + + )} - - - + + {/* Feed Card */} + {feed.type === 'timeline' ? ( + + ) : ( + + )} + + {/* Action Buttons */} + + {!isPinned && ( + + )} + + + + ) } function SectionHeaderText({children}: {children: React.ReactNode}) { - const t = useTheme() // eslint-disable-next-line bsky-internal/avoid-unwrapped-text return ( - + {children} ) @@ -382,7 +528,7 @@ function SectionHeaderText({children}: {children: React.ReactNode}) { function FollowingFeedCard() { const t = useTheme() return ( - +