[APP-1859] pinned feed drag n drop (#9893)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: vineyardbovines <spencerfpope@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Benzer
2026-02-25 11:52:10 -08:00
committed by GitHub
parent 49272be36b
commit 00816b70dc
17 changed files with 977 additions and 96 deletions
+489
View File
@@ -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<T> {
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<Animated.ScrollView>
/** Scroll offset shared value from useScrollViewOffset. */
scrollOffset?: SharedValue<number>
}
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<string, number>
/** Key of the item being dragged, or '' when idle. */
activeKey: string
/** Slot the active item started in. */
dragStartSlot: number
}
export function SortableList<T>({
data,
keyExtractor,
renderItem,
onReorder,
onDragStart,
onDragEnd,
itemHeight,
scrollRef,
scrollOffset,
}: SortableListProps<T>) {
const t = useTheme()
const state = useSharedValue<DragState>({
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<Animated.View>()
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<string, number> = {}
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<Animated.View>,
)
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 (
<Animated.View
ref={listRef}
style={[{height: data.length * itemHeight}, t.atoms.bg_contrast_25]}>
{sortedData.map(item => {
const key = keyExtractor(item)
return (
<SortableItem
key={key}
item={item}
itemKey={key}
itemCount={data.length}
itemHeight={itemHeight}
state={state}
dragY={dragY}
scrollCompensation={scrollCompensation}
isGestureActive={isGestureActive}
measureDone={measureDone}
renderItem={renderItem}
onCommitReorder={handleReorder}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
/>
)
})}
</Animated.View>
)
}
function SortableItem<T>({
item,
itemKey,
itemCount,
itemHeight,
state,
dragY,
scrollCompensation,
isGestureActive,
measureDone,
renderItem,
onCommitReorder,
onDragStart,
onDragEnd,
}: {
item: T
itemKey: string
itemCount: number
itemHeight: number
state: Animated.SharedValue<DragState>
dragY: Animated.SharedValue<number>
scrollCompensation: SharedValue<number>
isGestureActive: SharedValue<boolean>
measureDone: SharedValue<boolean>
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<string, number> = {}
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 = (
<GestureDetector gesture={gesture}>
<Animated.View
style={[
a.justify_center,
a.align_center,
a.px_sm,
a.py_md,
web({cursor: 'grab'}),
]}
hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}>
<GripIcon
size="lg"
fill={t.atoms.text_contrast_medium.color}
style={web({pointerEvents: 'none'})}
/>
</Animated.View>
</GestureDetector>
)
return (
<Animated.View
style={[
{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: itemHeight,
},
animatedStyle,
]}>
{renderItem(item, dragHandle)}
</Animated.View>
)
}
+168
View File
@@ -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<T> {
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<T>({
data,
keyExtractor,
renderItem,
onReorder,
onDragStart,
onDragEnd,
itemHeight,
}: SortableListProps<T>) {
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 (
<View
style={[
{height: data.length * itemHeight, position: 'relative'},
t.atoms.bg_contrast_25,
]}
// @ts-expect-error web-only pointer events
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}>
{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 = (
<div
onPointerDown={(e: React.PointerEvent<HTMLDivElement>) =>
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',
}}>
<GripIcon
size="lg"
fill={t.atoms.text_contrast_medium.color}
style={{pointerEvents: 'none'} as any}
/>
</div>
)
return (
<View
key={keyExtractor(item)}
style={[
{
position: 'absolute',
top: index * itemHeight,
left: 0,
right: 0,
height: itemHeight,
transform: [{translateY: isActive ? translationY : offset}],
scale: isActive ? 1.03 : 1,
zIndex: isActive ? 999 : 0,
boxShadow: isActive ? '0 2px 12px rgba(0,0,0,0.06)' : 'none',
// Animate scale/shadow on pickup, and transform for
// non-dragged items shifting into place.
transition: isActive
? 'box-shadow 200ms ease, scale 200ms ease'
: dragState
? 'transform 200ms ease'
: 'none',
} as any,
]}>
{renderItem(item, dragHandle)}
</View>
)
})}
</View>
)
}
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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 {
@@ -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'
+5 -1
View File
@@ -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',
})