From 00816b70dc263525daf26cb28f1eff56f3ca4575 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Wed, 25 Feb 2026 11:52:10 -0800 Subject: [PATCH] [APP-1859] pinned feed drag n drop (#9893) Co-authored-by: Samuel Newman Co-authored-by: vineyardbovines Co-authored-by: Claude Opus 4.6 --- .../dotGrid2x3_stroke2_corner2_rounded.svg | 1 + src/components/DraggableList/index.tsx | 489 ++++++++++++++++++ src/components/DraggableList/index.web.tsx | 168 ++++++ .../PostControls/PostMenu/index.tsx | 2 +- src/components/dms/ActionsWrapper.web.tsx | 2 +- src/components/dms/ConvoMenu.tsx | 2 +- .../dms/EmojiReactionPicker.web.tsx | 2 +- src/components/icons/DotGrid.tsx | 6 +- .../Profile/components/ProfileFeedHeader.tsx | 2 +- .../components/MoreOptionsMenu.tsx | 2 +- src/screens/SavedFeeds.tsx | 385 +++++++++++--- src/screens/Settings/Settings.tsx | 2 +- src/screens/StarterPack/StarterPackScreen.tsx | 2 +- src/view/com/composer/drafts/DraftItem.tsx | 2 +- src/view/com/profile/ProfileMenu.tsx | 2 +- src/view/shell/desktop/LeftNav.tsx | 2 +- .../shell/desktop/SidebarTrendingTopics.tsx | 2 +- 17 files changed, 977 insertions(+), 96 deletions(-) create mode 100644 assets/icons/dotGrid2x3_stroke2_corner2_rounded.svg create mode 100644 src/components/DraggableList/index.tsx create mode 100644 src/components/DraggableList/index.web.tsx diff --git a/assets/icons/dotGrid2x3_stroke2_corner2_rounded.svg b/assets/icons/dotGrid2x3_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..7b1cb66fc7 --- /dev/null +++ b/assets/icons/dotGrid2x3_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/DraggableList/index.tsx b/src/components/DraggableList/index.tsx new file mode 100644 index 0000000000..86076c20ae --- /dev/null +++ b/src/components/DraggableList/index.tsx @@ -0,0 +1,489 @@ +import {useLayoutEffect, useRef} from 'react' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import Animated, { + type AnimatedRef, + measure, + runOnJS, + scrollTo, + type SharedValue, + useAnimatedRef, + useAnimatedStyle, + useFrameCallback, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated' + +import {useHaptics} from '#/lib/haptics' +import {atoms as a, useTheme, web} from '#/alf' +import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid' +import {IS_IOS} from '#/env' + +/** + * Drag-to-reorder list. Items are absolutely positioned in a fixed-height + * container and animated via Reanimated shared values on the UI thread. + * + * All positioning is driven by a `slots` map (key → index) and translateY + * (no discrete `top` changes). On drag end the new slot assignment is + * computed on the UI thread first, then React state is updated via runOnJS. + * + * See SortableList.web.tsx for the web implementation using pointer events. + */ + +interface SortableListProps { + data: T[] + keyExtractor: (item: T) => string + renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode + onReorder: (data: T[]) => void + onDragStart?: () => void + onDragEnd?: () => void + /** Fixed row height used for position math. */ + itemHeight: number + /** Ref to the parent Animated.ScrollView for auto-scroll. */ + scrollRef?: AnimatedRef + /** Scroll offset shared value from useScrollViewOffset. */ + scrollOffset?: SharedValue +} + +const AUTO_SCROLL_THRESHOLD = 50 +const AUTO_SCROLL_SPEED = 4 + +/** + * Bundled into a single shared value so all fields update atomically + * in one set() call on the UI thread. + */ +interface DragState { + /** Maps each item key to its current slot index. */ + slots: Record + /** Key of the item being dragged, or '' when idle. */ + activeKey: string + /** Slot the active item started in. */ + dragStartSlot: number +} + +export function SortableList({ + data, + keyExtractor, + renderItem, + onReorder, + onDragStart, + onDragEnd, + itemHeight, + scrollRef, + scrollOffset, +}: SortableListProps) { + const t = useTheme() + const state = useSharedValue({ + slots: Object.fromEntries(data.map((item, i) => [keyExtractor(item), i])), + activeKey: '', + dragStartSlot: -1, + }) + const dragY = useSharedValue(0) + + // Auto-scroll shared values + const scrollCompensation = useSharedValue(0) + const isGestureActive = useSharedValue(false) + // We track scroll position ourselves because scrollOffset.get() lags + // by one frame after scrollTo(), causing a feedback loop where the + // frame callback keeps thinking the item is at the edge. + const trackedScrollY = useSharedValue(0) + + // For measuring list position within scroll content + const listRef = useAnimatedRef() + const listContentOffset = useSharedValue(0) + const viewportHeight = useSharedValue(0) + const measureDone = useSharedValue(false) + + // Sync slots when data changes externally (e.g. pin/unpin). + // Skip after our own reorder — the worklet already set correct slots + // on the UI thread, and a redundant JS-side set() would be wasteful. + const skipNextSync = useRef(false) + const currentKeys = data.map(item => keyExtractor(item)).join(',') + useLayoutEffect(() => { + if (skipNextSync.current) { + skipNextSync.current = false + return + } + const nextSlots: Record = {} + data.forEach((item, i) => { + nextSlots[keyExtractor(item)] = i + }) + state.set({slots: nextSlots, activeKey: '', dragStartSlot: -1}) + dragY.set(0) + }, [currentKeys, data, keyExtractor, state, dragY]) + + const handleReorder = (sortedKeys: string[]) => { + skipNextSync.current = true + const byKey = new Map(data.map(item => [keyExtractor(item), item])) + onReorder(sortedKeys.map(key => byKey.get(key)!)) + onDragEnd?.() + } + + // Auto-scroll: runs every frame while a gesture is active. + useFrameCallback(() => { + if (!isGestureActive.get()) return + if (!scrollRef || !scrollOffset) return + + const s = state.get() + if (s.activeKey === '') return + + // Measure list and scroll view on first frame of drag. + // Use scrollOffset here (only once) since no lag has occurred yet. + if (!measureDone.get()) { + const scrollM = measure( + scrollRef as unknown as AnimatedRef, + ) + const listM = measure(listRef) + if (!scrollM || !listM) return + trackedScrollY.set(scrollOffset.get()) + listContentOffset.set(listM.pageY - scrollM.pageY + trackedScrollY.get()) + viewportHeight.set(scrollM.height) + measureDone.set(true) + } + + const startSlot = s.dragStartSlot + const currentDragY = dragY.get() + + // Use trackedScrollY (not scrollOffset) to avoid the one-frame lag + // after scrollTo() that causes a feedback loop. + const scrollY = trackedScrollY.get() + + // Item position relative to scroll viewport top. + const itemContentY = + listContentOffset.get() + startSlot * itemHeight + currentDragY + const itemViewportY = itemContentY - scrollY + const itemBottomViewportY = itemViewportY + itemHeight + + let scrollDelta = 0 + if (itemViewportY < AUTO_SCROLL_THRESHOLD) { + scrollDelta = -AUTO_SCROLL_SPEED + } else if ( + itemBottomViewportY > + viewportHeight.get() - AUTO_SCROLL_THRESHOLD + ) { + scrollDelta = AUTO_SCROLL_SPEED + } + + if (scrollDelta === 0) return + + // Don't scroll if the item is already at a list boundary. + const effectiveSlotPos = + (startSlot * itemHeight + currentDragY) / itemHeight + if (scrollDelta < 0 && effectiveSlotPos <= 0) return + if (scrollDelta > 0 && effectiveSlotPos >= data.length - 1) return + + // Don't scroll past the top. + if (scrollDelta < 0 && scrollY <= 0) return + + const newScrollY = Math.max(0, scrollY + scrollDelta) + scrollTo(scrollRef, 0, newScrollY, false) + trackedScrollY.set(newScrollY) + scrollCompensation.set(scrollCompensation.get() + (newScrollY - scrollY)) + }) + + // Render in stable key order so React never reorders native views. + // On Android, native ViewGroup child reordering causes a visual flash. + const sortedData = [...data].sort((a, b) => { + const ka = keyExtractor(a) + const kb = keyExtractor(b) + return ka < kb ? -1 : ka > kb ? 1 : 0 + }) + + return ( + + {sortedData.map(item => { + const key = keyExtractor(item) + return ( + + ) + })} + + ) +} + +function SortableItem({ + item, + itemKey, + itemCount, + itemHeight, + state, + dragY, + scrollCompensation, + isGestureActive, + measureDone, + renderItem, + onCommitReorder, + onDragStart, + onDragEnd, +}: { + item: T + itemKey: string + itemCount: number + itemHeight: number + state: Animated.SharedValue + dragY: Animated.SharedValue + scrollCompensation: SharedValue + isGestureActive: SharedValue + measureDone: SharedValue + renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode + onCommitReorder: (sortedKeys: string[]) => void + onDragStart?: () => void + onDragEnd?: () => void +}) { + const t = useTheme() + const playHaptic = useHaptics() + + const lastHapticSlot = useSharedValue(-1) + + const gesture = Gesture.Pan() + .onStart(() => { + 'worklet' + const s = state.get() + const mySlot = s.slots[itemKey] + state.set({...s, activeKey: itemKey, dragStartSlot: mySlot}) + dragY.set(0) + scrollCompensation.set(0) + isGestureActive.set(true) + measureDone.set(false) + lastHapticSlot.set(mySlot) + if (onDragStart) { + runOnJS(onDragStart)() + } + runOnJS(playHaptic)() + }) + .onChange(e => { + 'worklet' + const startSlot = state.get().dragStartSlot + const minY = -startSlot * itemHeight + const maxY = (itemCount - 1 - startSlot) * itemHeight + // Include scroll compensation so the item tracks with auto-scroll. + const effectiveY = e.translationY + scrollCompensation.get() + const clampedY = Math.max(minY, Math.min(effectiveY, maxY)) + dragY.set(clampedY) + + const currentSlot = Math.round( + (startSlot * itemHeight + clampedY) / itemHeight, + ) + const clampedSlot = Math.max(0, Math.min(currentSlot, itemCount - 1)) + if (IS_IOS && clampedSlot !== lastHapticSlot.get()) { + lastHapticSlot.set(clampedSlot) + runOnJS(playHaptic)('Light') + } + }) + .onEnd(() => { + 'worklet' + // Stop auto-scroll BEFORE the snap animation. + isGestureActive.set(false) + const startSlot = state.get().dragStartSlot + const rawNewSlot = Math.round( + (startSlot * itemHeight + dragY.get()) / itemHeight, + ) + const newSlot = Math.max(0, Math.min(rawNewSlot, itemCount - 1)) + const snapOffset = (newSlot - startSlot) * itemHeight + + // Animate to the target slot, then commit. + dragY.set( + withTiming(snapOffset, {duration: 200}, finished => { + if (finished) { + if (newSlot !== startSlot) { + // Compute new slots on the UI thread so animated styles + // reflect final positions before React re-renders. + const cur = state.get() + const sorted: string[] = new Array(itemCount) + for (const key in cur.slots) { + sorted[cur.slots[key]] = key + } + const movedKey = sorted[startSlot] + sorted.splice(startSlot, 1) + sorted.splice(newSlot, 0, movedKey) + + const nextSlots: Record = {} + for (let i = 0; i < sorted.length; i++) { + nextSlots[sorted[i]] = i + } + + state.set({ + slots: nextSlots, + activeKey: '', + dragStartSlot: -1, + }) + dragY.set(0) + runOnJS(onCommitReorder)(sorted) + } else { + const s = state.get() + state.set({...s, activeKey: '', dragStartSlot: -1}) + dragY.set(0) + if (onDragEnd) { + runOnJS(onDragEnd)() + } + } + } + }), + ) + }) + // Reset if the gesture is cancelled without onEnd firing. + .onFinalize(() => { + 'worklet' + isGestureActive.set(false) + if (state.get().activeKey === itemKey && dragY.get() === 0) { + const s = state.get() + state.set({...s, activeKey: '', dragStartSlot: -1}) + if (onDragEnd) { + runOnJS(onDragEnd)() + } + } + }) + + // All vertical positioning is via translateY (no `top`). This avoids + // discrete jumps when slots change — Reanimated smoothly animates from + // the current translateY to the new target on every state transition. + // On first mount we skip the animation so items appear instantly. + const isFirstRender = useSharedValue(true) + + const animatedStyle = useAnimatedStyle(() => { + const s = state.get() + const mySlot = s.slots[itemKey] + if (mySlot === undefined) { + return {} + } + const baseY = mySlot * itemHeight + + // Active item: follow the finger with a slight scale-up and shadow. + if (s.activeKey === itemKey) { + return { + transform: [ + {translateY: s.dragStartSlot * itemHeight + dragY.get()}, + {scale: withSpring(1.03)}, + ], + zIndex: 999, + ...(IS_IOS + ? { + shadowColor: '#000', + shadowOffset: {width: 0, height: 1}, + shadowOpacity: withSpring(0.08), + shadowRadius: withSpring(4), + } + : { + elevation: withSpring(3), + }), + } + } + + // Reset for non-active states. Without this, shadow props + // set during dragging linger on the native view. + const inactive = { + ...(IS_IOS + ? { + shadowOpacity: withSpring(0), + shadowRadius: withSpring(0), + } + : { + elevation: withSpring(0), + }), + } + + // Another item is being dragged — shift to make room. + if (s.activeKey !== '') { + isFirstRender.set(false) + const currentDragPos = Math.round( + (s.dragStartSlot * itemHeight + dragY.get()) / itemHeight, + ) + const clampedPos = Math.max(0, Math.min(currentDragPos, itemCount - 1)) + + let offset = 0 + if ( + s.dragStartSlot < clampedPos && + mySlot > s.dragStartSlot && + mySlot <= clampedPos + ) { + offset = -itemHeight + } else if ( + s.dragStartSlot > clampedPos && + mySlot < s.dragStartSlot && + mySlot >= clampedPos + ) { + offset = itemHeight + } + + return { + transform: [ + {translateY: withTiming(baseY + offset, {duration: 200})}, + {scale: withSpring(1)}, + ], + zIndex: 0, + ...inactive, + } + } + + // Idle: sit at our slot. On first render use a direct value so items + // don't animate from y=0. After any drag, use withTiming so the + // shift→idle transition is smooth (no discrete jump). + if (isFirstRender.get()) { + isFirstRender.set(false) + return { + transform: [{translateY: baseY}, {scale: 1}], + zIndex: 0, + ...inactive, + } + } + + return { + transform: [{translateY: withTiming(baseY, {duration: 200})}, {scale: 1}], + zIndex: 0, + ...inactive, + } + }) + + const dragHandle = ( + + + + + + ) + + return ( + + {renderItem(item, dragHandle)} + + ) +} diff --git a/src/components/DraggableList/index.web.tsx b/src/components/DraggableList/index.web.tsx new file mode 100644 index 0000000000..237a11be35 --- /dev/null +++ b/src/components/DraggableList/index.web.tsx @@ -0,0 +1,168 @@ +import {useState} from 'react' +import {View} from 'react-native' + +import {useTheme} from '#/alf' +import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid' + +/** + * Web implementation of SortableList using pointer events. + * See SortableList.tsx for the native version using gesture-handler + Reanimated. + */ + +interface SortableListProps { + data: T[] + keyExtractor: (item: T) => string + renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode + onReorder: (data: T[]) => void + onDragStart?: () => void + onDragEnd?: () => void + /** Fixed row height used for position math. */ + itemHeight: number +} + +export function SortableList({ + data, + keyExtractor, + renderItem, + onReorder, + onDragStart, + onDragEnd, + itemHeight, +}: SortableListProps) { + const t = useTheme() + const [dragState, setDragState] = useState<{ + activeIndex: number + currentY: number + startY: number + } | null>(null) + + const getNewPosition = (state: { + activeIndex: number + currentY: number + startY: number + }) => { + const translationY = state.currentY - state.startY + const rawNewPos = Math.round( + (state.activeIndex * itemHeight + translationY) / itemHeight, + ) + return Math.max(0, Math.min(rawNewPos, data.length - 1)) + } + + const handlePointerMove = (e: React.PointerEvent) => { + if (!dragState) return + e.preventDefault() + setDragState(prev => (prev ? {...prev, currentY: e.clientY} : null)) + } + + const handlePointerUp = () => { + if (!dragState) return + const newPos = getNewPosition(dragState) + if (newPos !== dragState.activeIndex) { + const next = [...data] + const [moved] = next.splice(dragState.activeIndex, 1) + next.splice(newPos, 0, moved) + onReorder(next) + } + setDragState(null) + onDragEnd?.() + } + + const handlePointerDown = (e: React.PointerEvent, index: number) => { + e.preventDefault() + ;(e.target as HTMLElement).setPointerCapture(e.pointerId) + setDragState({activeIndex: index, currentY: e.clientY, startY: e.clientY}) + onDragStart?.() + } + + const newPos = dragState ? getNewPosition(dragState) : -1 + + return ( + + {data.map((item, index) => { + const isActive = dragState?.activeIndex === index + + // Clamp translation so the item stays within list bounds. + const rawTranslationY = isActive + ? dragState.currentY - dragState.startY + : 0 + const translationY = isActive + ? Math.max( + -index * itemHeight, + Math.min(rawTranslationY, (data.length - 1 - index) * itemHeight), + ) + : 0 + + // Non-dragged items shift to make room for the dragged item. + let offset = 0 + if (dragState && !isActive) { + const orig = dragState.activeIndex + if (orig < newPos && index > orig && index <= newPos) { + offset = -itemHeight + } else if (orig > newPos && index < orig && index >= newPos) { + offset = itemHeight + } + } + + const dragHandle = ( +
) => + handlePointerDown(e, index) + } + style={{ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + paddingLeft: 8, + paddingRight: 8, + paddingTop: 12, + paddingBottom: 12, + cursor: isActive ? 'grabbing' : 'grab', + touchAction: 'none', + userSelect: 'none', + }}> + +
+ ) + + return ( + + {renderItem(item, dragHandle)} + + ) + })} +
+ ) +} diff --git a/src/components/PostControls/PostMenu/index.tsx b/src/components/PostControls/PostMenu/index.tsx index 65a948d69b..dbf0744990 100644 --- a/src/components/PostControls/PostMenu/index.tsx +++ b/src/components/PostControls/PostMenu/index.tsx @@ -11,7 +11,7 @@ import {useLingui} from '@lingui/react' import {type Shadow} from '#/state/cache/post-shadow' import {EventStopper} from '#/view/com/util/EventStopper' -import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' import {useMenuControl} from '#/components/Menu' import * as Menu from '#/components/Menu' import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index 18eb4161cf..6b95e7fbaa 100644 --- a/src/components/dms/ActionsWrapper.web.tsx +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -9,7 +9,7 @@ import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {MessageContextMenu} from '#/components/dms/MessageContextMenu' -import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {EmojiReactionPicker} from './EmojiReactionPicker' import {hasReachedReactionLimit} from './util' diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index 713605a7af..6fcca20814 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -24,7 +24,7 @@ import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' -import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' import { diff --git a/src/components/dms/EmojiReactionPicker.web.tsx b/src/components/dms/EmojiReactionPicker.web.tsx index 6a96238797..6be85efb4c 100644 --- a/src/components/dms/EmojiReactionPicker.web.tsx +++ b/src/components/dms/EmojiReactionPicker.web.tsx @@ -10,7 +10,7 @@ import {useSession} from '#/state/session' import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker' import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji' import {atoms as a, flatten, useTheme} from '#/alf' -import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' import * as Menu from '#/components/Menu' import {type TriggerProps} from '#/components/Menu/types' import {Text} from '#/components/Typography' diff --git a/src/components/icons/DotGrid.tsx b/src/components/icons/DotGrid.tsx index c50d7a440f..2a21026664 100644 --- a/src/components/icons/DotGrid.tsx +++ b/src/components/icons/DotGrid.tsx @@ -1,5 +1,9 @@ import {createSinglePathSVG} from './TEMPLATE' -export const DotGrid_Stroke2_Corner0_Rounded = createSinglePathSVG({ +export const DotGrid3x1_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M2 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm16 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm-6-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z', }) + +export const DotGrid2x3_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M9 17a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm-6-7a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4ZM9 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z', +}) diff --git a/src/screens/Profile/components/ProfileFeedHeader.tsx b/src/screens/Profile/components/ProfileFeedHeader.tsx index 2201fee749..58f6fc5942 100644 --- a/src/screens/Profile/components/ProfileFeedHeader.tsx +++ b/src/screens/Profile/components/ProfileFeedHeader.tsx @@ -30,7 +30,7 @@ import {Divider} from '#/components/Divider' import {useRichText} from '#/components/hooks/useRichText' import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import { Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, Heart2_Stroke2_Corner0_Rounded as Heart, diff --git a/src/screens/ProfileList/components/MoreOptionsMenu.tsx b/src/screens/ProfileList/components/MoreOptionsMenu.tsx index d414c53514..d15664618c 100644 --- a/src/screens/ProfileList/components/MoreOptionsMenu.tsx +++ b/src/screens/ProfileList/components/MoreOptionsMenu.tsx @@ -20,7 +20,7 @@ import {useDialogControl} from '#/components/Dialog' import {CreateOrEditListDialog} from '#/components/dialogs/lists/CreateOrEditListDialog' import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox' import {ChainLink_Stroke2_Corner0_Rounded as ChainLink} from '#/components/icons/ChainLink' -import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' import {PencilLine_Stroke2_Corner0_Rounded as PencilLineIcon} from '#/components/icons/Pencil' import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheckIcon} from '#/components/icons/Person' import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' diff --git a/src/screens/SavedFeeds.tsx b/src/screens/SavedFeeds.tsx index df57297241..4b8fcaddbc 100644 --- a/src/screens/SavedFeeds.tsx +++ b/src/screens/SavedFeeds.tsx @@ -1,6 +1,7 @@ import {useCallback, useState} from 'react' import {View} from 'react-native' -import Animated, {LinearTransition} from 'react-native-reanimated' +import type Animated from 'react-native-reanimated' +import {useAnimatedRef, useScrollViewOffset} from 'react-native-reanimated' import {type AppBskyActorDefs} from '@atproto/api' import {TID} from '@atproto/common-web' import {msg} from '@lingui/core/macro' @@ -16,6 +17,7 @@ import { type NavigationProp, } from '#/lib/routes/types' import {logger} from '#/logger' +import {useA11y} from '#/state/a11y' import { useOverwriteSavedFeedsMutation, usePreferencesQuery, @@ -29,6 +31,7 @@ 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 {SortableList} from '#/components/DraggableList' import { ArrowBottom_Stroke2_Corner0_Rounded as ArrowDownIcon, ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon, @@ -45,9 +48,13 @@ import {Text} from '#/components/Typography' type Props = NativeStackScreenProps export function SavedFeeds({}: Props) { const {data: preferences} = usePreferencesQuery() + const {screenReaderEnabled} = useA11y() if (!preferences) { return } + if (screenReaderEnabled) { + return + } return } @@ -63,6 +70,8 @@ function SavedFeedsInner({ const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = useOverwriteSavedFeedsMutation() const navigation = useNavigation() + const scrollRef = useAnimatedRef() + const scrollOffset = useScrollViewOffset(scrollRef) /* * Use optimistic data if exists and no error, otherwise fallback to remote @@ -77,6 +86,7 @@ function SavedFeedsInner({ const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 const noFollowingFeed = currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType + const [isDragging, setIsDragging] = useState(false) useFocusEffect( useCallback(() => { @@ -122,7 +132,7 @@ function SavedFeedsInner({ - + {noSavedFeedsOfAnyType && ( ) : ( - pinnedFeeds.map(f => ( - - )) + f.id} + itemHeight={68} + scrollRef={scrollRef} + scrollOffset={scrollOffset} + onDragStart={() => setIsDragging(true)} + onDragEnd={() => setIsDragging(false)} + onReorder={reordered => { + setCurrentFeeds([...reordered, ...unpinnedFeeds]) + }} + renderItem={(feed, dragHandle) => ( + + )} + /> ) ) : ( @@ -193,13 +213,11 @@ function SavedFeedsInner({ ) : ( unpinnedFeeds.map(f => ( - )) ) @@ -231,24 +249,209 @@ function SavedFeedsInner({ ) } -function ListItem({ +function SavedFeedsA11y({ + preferences, +}: { + preferences: UsePreferencesQueryResponse +}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const setMinimalShellMode = useSetMinimalShellMode() + const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = + useOverwriteSavedFeedsMutation() + const navigation = useNavigation() + + const [currentFeeds, setCurrentFeeds] = useState( + () => preferences.savedFeeds || [], + ) + const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds + const pinnedFeeds = currentFeeds.filter(f => f.pinned) + const unpinnedFeeds = currentFeeds.filter(f => !f.pinned) + const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 + const noFollowingFeed = + currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType + + useFocusEffect( + useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const onSaveChanges = async () => { + try { + await overwriteSavedFeeds(currentFeeds) + Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Feeds') + } + } catch (e) { + Toast.show(_(msg`There was an issue contacting the server`), 'xmark') + logger.error('Failed to toggle pinned feed', {message: e}) + } + } + + const onMoveUp = (index: number) => { + const pinned = [...pinnedFeeds] + ;[pinned[index - 1], pinned[index]] = [pinned[index], pinned[index - 1]] + setCurrentFeeds([...pinned, ...unpinnedFeeds]) + } + + const onMoveDown = (index: number) => { + const pinned = [...pinnedFeeds] + ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] + setCurrentFeeds([...pinned, ...unpinnedFeeds]) + } + + return ( + + + + + + Feeds + + + + + + + {noSavedFeedsOfAnyType && ( + + + setCurrentFeeds( + RECOMMENDED_SAVED_FEEDS.map(f => ({ + ...f, + id: TID.nextStr(), + })), + ) + } + /> + + )} + + + Pinned Feeds + + + {!pinnedFeeds.length ? ( + + + You don't have any pinned feeds. + + + ) : ( + pinnedFeeds.map((feed, i) => ( + onMoveUp(i)} + onMoveDown={() => onMoveDown(i)} + /> + )) + )} + + {noFollowingFeed && ( + + + setCurrentFeeds(feeds => [ + ...feeds, + {...TIMELINE_SAVED_FEED, id: TID.next().toString()}, + ]) + } + /> + + )} + + + Saved Feeds + + + {!unpinnedFeeds.length ? ( + + + You don't have any saved feeds. + + + ) : ( + unpinnedFeeds.map(f => ( + + )) + )} + + + + + Feeds are custom algorithms that users build with a little coding + expertise.{' '} + + See this guide + {' '} + for more information. + + + + + + ) +} + +function PinnedFeedItem({ feed, - isPinned, currentFeeds, setCurrentFeeds, + dragHandle, + index, + total, + onMoveUp, + onMoveDown, }: { feed: AppBskyActorDefs.SavedFeed - isPinned: boolean currentFeeds: AppBskyActorDefs.SavedFeed[] - setCurrentFeeds: React.Dispatch - preferences: UsePreferencesQueryResponse + setCurrentFeeds: React.Dispatch< + React.SetStateAction + > + dragHandle?: React.ReactNode + index?: number + total?: number + onMoveUp?: () => void + onMoveDown?: () => void }) { const {_} = useLingui() const t = useTheme() const playHaptic = useHaptics() const feedUri = feed.value - const onTogglePinned = async () => { + const onTogglePinned = () => { playHaptic() setCurrentFeeds( currentFeeds.map(f => @@ -257,68 +460,35 @@ 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 ? ( + + + {onMoveUp !== undefined ? ( <> ) : ( - + dragHandle )} + + + ) +} + +function UnpinnedFeedItem({ + feed, + currentFeeds, + setCurrentFeeds, +}: { + feed: AppBskyActorDefs.SavedFeed + currentFeeds: AppBskyActorDefs.SavedFeed[] + setCurrentFeeds: React.Dispatch< + React.SetStateAction + > +}) { + const {_} = useLingui() + const t = useTheme() + const playHaptic = useHaptics() + const feedUri = feed.value + + const onTogglePinned = () => { + playHaptic() + setCurrentFeeds( + currentFeeds.map(f => + f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, + ), + ) + } + + const onPressRemove = () => { + playHaptic() + setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) + } + + return ( + + {feed.type === 'timeline' ? ( + + ) : ( + + )} + + - + ) } diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 245a70f470..575bd4cce1 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -45,7 +45,7 @@ import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/ import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' import {Contacts_Stroke2_Corner2_Rounded as ContactsIcon} from '#/components/icons/Contacts' -import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock' import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx index e08cbd01b6..cb3ef828b5 100644 --- a/src/screens/StarterPack/StarterPackScreen.tsx +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -55,7 +55,7 @@ import {CreateListFromStarterPackDialog} from '#/components/dialogs/lists/Create import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' diff --git a/src/view/com/composer/drafts/DraftItem.tsx b/src/view/com/composer/drafts/DraftItem.tsx index 84d9e1a907..8859216c31 100644 --- a/src/view/com/composer/drafts/DraftItem.tsx +++ b/src/view/com/composer/drafts/DraftItem.tsx @@ -11,7 +11,7 @@ import {atoms as a, select, useTheme} from '#/alf' import {Button} from '#/components/Button' import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlusIcon} from '#/components/icons/CirclePlus' import {type Props as SVGIconProps} from '#/components/icons/common' -import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid' import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import * as MediaPreview from '#/components/MediaPreview' diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 209f42ba9d..900989c0c6 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -32,7 +32,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' -import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 518bd15600..a35f3ec1c0 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -43,7 +43,7 @@ import { BulletList_Filled_Corner0_Rounded as ListFilled, BulletList_Stroke2_Corner0_Rounded as List, } from '#/components/icons/BulletList' -import {DotGrid_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' import {EditBig_Stroke2_Corner0_Rounded as EditBig} from '#/components/icons/EditBig' import { Hashtag_Filled_Corner0_Rounded as HashtagFilled, diff --git a/src/view/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx index d396441d4c..3a054faa69 100644 --- a/src/view/shell/desktop/SidebarTrendingTopics.tsx +++ b/src/view/shell/desktop/SidebarTrendingTopics.tsx @@ -11,7 +11,7 @@ import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics' import {useTrendingConfig} from '#/state/service-config' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' -import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' import * as Prompt from '#/components/Prompt' import {TrendingTopicLink} from '#/components/TrendingTopics'