[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:
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="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"/></svg>
|
||||||
|
After Width: | Height: | Size: 303 B |
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 {type Shadow} from '#/state/cache/post-shadow'
|
||||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
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 {useMenuControl} from '#/components/Menu'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
|
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {useSession} from '#/state/session'
|
|||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
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 {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
||||||
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
||||||
import {hasReachedReactionLimit} from './util'
|
import {hasReachedReactionLimit} from './util'
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
|||||||
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
|
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
|
||||||
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
|
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
|
||||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
|
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 {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
|
||||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {useSession} from '#/state/session'
|
|||||||
import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker'
|
import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker'
|
||||||
import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji'
|
import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji'
|
||||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
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 * as Menu from '#/components/Menu'
|
||||||
import {type TriggerProps} from '#/components/Menu/types'
|
import {type TriggerProps} from '#/components/Menu/types'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import {createSinglePathSVG} from './TEMPLATE'
|
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',
|
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',
|
||||||
|
})
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {Divider} from '#/components/Divider'
|
|||||||
import {useRichText} from '#/components/hooks/useRichText'
|
import {useRichText} from '#/components/hooks/useRichText'
|
||||||
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
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 {
|
import {
|
||||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
|
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
|
||||||
Heart2_Stroke2_Corner0_Rounded as Heart,
|
Heart2_Stroke2_Corner0_Rounded as Heart,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {useDialogControl} from '#/components/Dialog'
|
|||||||
import {CreateOrEditListDialog} from '#/components/dialogs/lists/CreateOrEditListDialog'
|
import {CreateOrEditListDialog} from '#/components/dialogs/lists/CreateOrEditListDialog'
|
||||||
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
|
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
|
||||||
import {ChainLink_Stroke2_Corner0_Rounded as ChainLink} from '#/components/icons/ChainLink'
|
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 {PencilLine_Stroke2_Corner0_Rounded as PencilLineIcon} from '#/components/icons/Pencil'
|
||||||
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheckIcon} from '#/components/icons/Person'
|
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheckIcon} from '#/components/icons/Person'
|
||||||
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
||||||
|
|||||||
+290
-71
@@ -1,6 +1,7 @@
|
|||||||
import {useCallback, useState} from 'react'
|
import {useCallback, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
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 {type AppBskyActorDefs} from '@atproto/api'
|
||||||
import {TID} from '@atproto/common-web'
|
import {TID} from '@atproto/common-web'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
type NavigationProp,
|
type NavigationProp,
|
||||||
} from '#/lib/routes/types'
|
} from '#/lib/routes/types'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {useA11y} from '#/state/a11y'
|
||||||
import {
|
import {
|
||||||
useOverwriteSavedFeedsMutation,
|
useOverwriteSavedFeedsMutation,
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
@@ -29,6 +31,7 @@ import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
|
|||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {SortableList} from '#/components/DraggableList'
|
||||||
import {
|
import {
|
||||||
ArrowBottom_Stroke2_Corner0_Rounded as ArrowDownIcon,
|
ArrowBottom_Stroke2_Corner0_Rounded as ArrowDownIcon,
|
||||||
ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon,
|
ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon,
|
||||||
@@ -45,9 +48,13 @@ import {Text} from '#/components/Typography'
|
|||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
|
||||||
export function SavedFeeds({}: Props) {
|
export function SavedFeeds({}: Props) {
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
const {screenReaderEnabled} = useA11y()
|
||||||
if (!preferences) {
|
if (!preferences) {
|
||||||
return <View />
|
return <View />
|
||||||
}
|
}
|
||||||
|
if (screenReaderEnabled) {
|
||||||
|
return <SavedFeedsA11y preferences={preferences} />
|
||||||
|
}
|
||||||
return <SavedFeedsInner preferences={preferences} />
|
return <SavedFeedsInner preferences={preferences} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +70,8 @@ function SavedFeedsInner({
|
|||||||
const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} =
|
const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} =
|
||||||
useOverwriteSavedFeedsMutation()
|
useOverwriteSavedFeedsMutation()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const scrollRef = useAnimatedRef<Animated.ScrollView>()
|
||||||
|
const scrollOffset = useScrollViewOffset(scrollRef)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Use optimistic data if exists and no error, otherwise fallback to remote
|
* 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 noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0
|
||||||
const noFollowingFeed =
|
const noFollowingFeed =
|
||||||
currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType
|
currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -122,7 +132,7 @@ function SavedFeedsInner({
|
|||||||
</Button>
|
</Button>
|
||||||
</Layout.Header.Outer>
|
</Layout.Header.Outer>
|
||||||
|
|
||||||
<Layout.Content>
|
<Layout.Content ref={scrollRef} scrollEnabled={!isDragging}>
|
||||||
{noSavedFeedsOfAnyType && (
|
{noSavedFeedsOfAnyType && (
|
||||||
<View style={[t.atoms.border_contrast_low, a.border_b]}>
|
<View style={[t.atoms.border_contrast_low, a.border_b]}>
|
||||||
<NoSavedFeedsOfAnyType
|
<NoSavedFeedsOfAnyType
|
||||||
@@ -150,16 +160,26 @@ function SavedFeedsInner({
|
|||||||
</Admonition>
|
</Admonition>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
pinnedFeeds.map(f => (
|
<SortableList
|
||||||
<ListItem
|
data={pinnedFeeds}
|
||||||
key={f.id}
|
keyExtractor={f => f.id}
|
||||||
feed={f}
|
itemHeight={68}
|
||||||
isPinned
|
scrollRef={scrollRef}
|
||||||
|
scrollOffset={scrollOffset}
|
||||||
|
onDragStart={() => setIsDragging(true)}
|
||||||
|
onDragEnd={() => setIsDragging(false)}
|
||||||
|
onReorder={reordered => {
|
||||||
|
setCurrentFeeds([...reordered, ...unpinnedFeeds])
|
||||||
|
}}
|
||||||
|
renderItem={(feed, dragHandle) => (
|
||||||
|
<PinnedFeedItem
|
||||||
|
feed={feed}
|
||||||
currentFeeds={currentFeeds}
|
currentFeeds={currentFeeds}
|
||||||
setCurrentFeeds={setCurrentFeeds}
|
setCurrentFeeds={setCurrentFeeds}
|
||||||
preferences={preferences}
|
dragHandle={dragHandle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
))
|
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<View style={[a.w_full, a.py_2xl, a.align_center]}>
|
<View style={[a.w_full, a.py_2xl, a.align_center]}>
|
||||||
@@ -193,13 +213,11 @@ function SavedFeedsInner({
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
unpinnedFeeds.map(f => (
|
unpinnedFeeds.map(f => (
|
||||||
<ListItem
|
<UnpinnedFeedItem
|
||||||
key={f.id}
|
key={f.id}
|
||||||
feed={f}
|
feed={f}
|
||||||
isPinned={false}
|
|
||||||
currentFeeds={currentFeeds}
|
currentFeeds={currentFeeds}
|
||||||
setCurrentFeeds={setCurrentFeeds}
|
setCurrentFeeds={setCurrentFeeds}
|
||||||
preferences={preferences}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
@@ -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<NavigationProp>()
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Layout.Screen>
|
||||||
|
<Layout.Header.Outer>
|
||||||
|
<Layout.Header.BackButton />
|
||||||
|
<Layout.Header.Content align="left">
|
||||||
|
<Layout.Header.TitleText>
|
||||||
|
<Trans>Feeds</Trans>
|
||||||
|
</Layout.Header.TitleText>
|
||||||
|
</Layout.Header.Content>
|
||||||
|
<Button
|
||||||
|
testID="saveChangesBtn"
|
||||||
|
size="small"
|
||||||
|
color={hasUnsavedChanges ? 'primary' : 'secondary'}
|
||||||
|
onPress={onSaveChanges}
|
||||||
|
label={_(msg`Save changes`)}
|
||||||
|
disabled={isOverwritePending || !hasUnsavedChanges}>
|
||||||
|
<ButtonIcon icon={isOverwritePending ? Loader : SaveIcon} />
|
||||||
|
<ButtonText>
|
||||||
|
{gtMobile ? <Trans>Save changes</Trans> : <Trans>Save</Trans>}
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</Layout.Header.Outer>
|
||||||
|
|
||||||
|
<Layout.Content>
|
||||||
|
{noSavedFeedsOfAnyType && (
|
||||||
|
<View style={[t.atoms.border_contrast_low, a.border_b]}>
|
||||||
|
<NoSavedFeedsOfAnyType
|
||||||
|
onAddRecommendedFeeds={() =>
|
||||||
|
setCurrentFeeds(
|
||||||
|
RECOMMENDED_SAVED_FEEDS.map(f => ({
|
||||||
|
...f,
|
||||||
|
id: TID.nextStr(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionHeaderText>
|
||||||
|
<Trans>Pinned Feeds</Trans>
|
||||||
|
</SectionHeaderText>
|
||||||
|
|
||||||
|
{!pinnedFeeds.length ? (
|
||||||
|
<View style={[a.flex_1, a.p_lg]}>
|
||||||
|
<Admonition type="info">
|
||||||
|
<Trans>You don't have any pinned feeds.</Trans>
|
||||||
|
</Admonition>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
pinnedFeeds.map((feed, i) => (
|
||||||
|
<PinnedFeedItem
|
||||||
|
key={feed.id}
|
||||||
|
feed={feed}
|
||||||
|
currentFeeds={currentFeeds}
|
||||||
|
setCurrentFeeds={setCurrentFeeds}
|
||||||
|
index={i}
|
||||||
|
total={pinnedFeeds.length}
|
||||||
|
onMoveUp={() => onMoveUp(i)}
|
||||||
|
onMoveDown={() => onMoveDown(i)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{noFollowingFeed && (
|
||||||
|
<View style={[t.atoms.border_contrast_low, a.border_b]}>
|
||||||
|
<NoFollowingFeed
|
||||||
|
onAddFeed={() =>
|
||||||
|
setCurrentFeeds(feeds => [
|
||||||
|
...feeds,
|
||||||
|
{...TIMELINE_SAVED_FEED, id: TID.next().toString()},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionHeaderText>
|
||||||
|
<Trans>Saved Feeds</Trans>
|
||||||
|
</SectionHeaderText>
|
||||||
|
|
||||||
|
{!unpinnedFeeds.length ? (
|
||||||
|
<View style={[a.flex_1, a.p_lg]}>
|
||||||
|
<Admonition type="info">
|
||||||
|
<Trans>You don't have any saved feeds.</Trans>
|
||||||
|
</Admonition>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
unpinnedFeeds.map(f => (
|
||||||
|
<UnpinnedFeedItem
|
||||||
|
key={f.id}
|
||||||
|
feed={f}
|
||||||
|
currentFeeds={currentFeeds}
|
||||||
|
setCurrentFeeds={setCurrentFeeds}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={[a.px_lg, a.py_xl]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||||
|
<Trans>
|
||||||
|
Feeds are custom algorithms that users build with a little coding
|
||||||
|
expertise.{' '}
|
||||||
|
<InlineLinkText
|
||||||
|
to="https://github.com/bluesky-social/feed-generator"
|
||||||
|
label={_(msg`See this guide`)}
|
||||||
|
disableMismatchWarning
|
||||||
|
style={[a.leading_snug]}>
|
||||||
|
See this guide
|
||||||
|
</InlineLinkText>{' '}
|
||||||
|
for more information.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout.Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PinnedFeedItem({
|
||||||
feed,
|
feed,
|
||||||
isPinned,
|
|
||||||
currentFeeds,
|
currentFeeds,
|
||||||
setCurrentFeeds,
|
setCurrentFeeds,
|
||||||
|
dragHandle,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
}: {
|
}: {
|
||||||
feed: AppBskyActorDefs.SavedFeed
|
feed: AppBskyActorDefs.SavedFeed
|
||||||
isPinned: boolean
|
|
||||||
currentFeeds: AppBskyActorDefs.SavedFeed[]
|
currentFeeds: AppBskyActorDefs.SavedFeed[]
|
||||||
setCurrentFeeds: React.Dispatch<AppBskyActorDefs.SavedFeed[]>
|
setCurrentFeeds: React.Dispatch<
|
||||||
preferences: UsePreferencesQueryResponse
|
React.SetStateAction<AppBskyActorDefs.SavedFeed[]>
|
||||||
|
>
|
||||||
|
dragHandle?: React.ReactNode
|
||||||
|
index?: number
|
||||||
|
total?: number
|
||||||
|
onMoveUp?: () => void
|
||||||
|
onMoveDown?: () => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const playHaptic = useHaptics()
|
const playHaptic = useHaptics()
|
||||||
const feedUri = feed.value
|
const feedUri = feed.value
|
||||||
|
|
||||||
const onTogglePinned = async () => {
|
const onTogglePinned = () => {
|
||||||
playHaptic()
|
playHaptic()
|
||||||
setCurrentFeeds(
|
setCurrentFeeds(
|
||||||
currentFeeds.map(f =>
|
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 (
|
return (
|
||||||
<Animated.View
|
<View style={[a.flex_row, t.atoms.bg]}>
|
||||||
style={[a.flex_row, a.border_b, t.atoms.border_contrast_low]}
|
|
||||||
layout={LinearTransition.duration(100)}>
|
|
||||||
{feed.type === 'timeline' ? (
|
{feed.type === 'timeline' ? (
|
||||||
<FollowingFeedCard />
|
<FollowingFeedCard />
|
||||||
) : (
|
) : (
|
||||||
<FeedSourceCard
|
<FeedSourceCard
|
||||||
key={feedUri}
|
|
||||||
feedUri={feedUri}
|
feedUri={feedUri}
|
||||||
style={[isPinned && a.pr_sm]}
|
style={[a.pr_sm]}
|
||||||
showMinimalPlaceholder
|
showMinimalPlaceholder
|
||||||
hideTopBorder={true}
|
hideTopBorder={true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<View style={[a.pr_lg, a.flex_row, a.align_center, a.gap_sm]}>
|
<View style={[a.pr_sm, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
{isPinned ? (
|
<Button
|
||||||
|
testID={`feed-${feed.type}-togglePin`}
|
||||||
|
label={_(msg`Unpin feed`)}
|
||||||
|
onPress={onTogglePinned}
|
||||||
|
size="small"
|
||||||
|
color="primary_subtle"
|
||||||
|
shape="square">
|
||||||
|
<ButtonIcon icon={PinIcon} />
|
||||||
|
</Button>
|
||||||
|
{onMoveUp !== undefined ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
testID={`feed-${feed.type}-moveUp`}
|
testID={`feed-${feed.type}-moveUp`}
|
||||||
label={_(msg`Move feed up`)}
|
label={_(msg`Move feed up`)}
|
||||||
onPress={onPressUp}
|
onPress={onMoveUp}
|
||||||
|
disabled={index === 0}
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
shape="square">
|
shape="square">
|
||||||
@@ -327,7 +497,8 @@ function ListItem({
|
|||||||
<Button
|
<Button
|
||||||
testID={`feed-${feed.type}-moveDown`}
|
testID={`feed-${feed.type}-moveDown`}
|
||||||
label={_(msg`Move feed down`)}
|
label={_(msg`Move feed down`)}
|
||||||
onPress={onPressDown}
|
onPress={onMoveDown}
|
||||||
|
disabled={index === total! - 1}
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
shape="square">
|
shape="square">
|
||||||
@@ -335,6 +506,55 @@ function ListItem({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
dragHandle
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnpinnedFeedItem({
|
||||||
|
feed,
|
||||||
|
currentFeeds,
|
||||||
|
setCurrentFeeds,
|
||||||
|
}: {
|
||||||
|
feed: AppBskyActorDefs.SavedFeed
|
||||||
|
currentFeeds: AppBskyActorDefs.SavedFeed[]
|
||||||
|
setCurrentFeeds: React.Dispatch<
|
||||||
|
React.SetStateAction<AppBskyActorDefs.SavedFeed[]>
|
||||||
|
>
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<View style={[a.flex_row, a.border_b, t.atoms.border_contrast_low]}>
|
||||||
|
{feed.type === 'timeline' ? (
|
||||||
|
<FollowingFeedCard />
|
||||||
|
) : (
|
||||||
|
<FeedSourceCard
|
||||||
|
feedUri={feedUri}
|
||||||
|
showMinimalPlaceholder
|
||||||
|
hideTopBorder={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View style={[a.pr_lg, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Button
|
<Button
|
||||||
testID={`feed-${feedUri}-toggleSave`}
|
testID={`feed-${feedUri}-toggleSave`}
|
||||||
label={_(msg`Remove from my feeds`)}
|
label={_(msg`Remove from my feeds`)}
|
||||||
@@ -345,18 +565,17 @@ function ListItem({
|
|||||||
shape="square">
|
shape="square">
|
||||||
<ButtonIcon icon={TrashIcon} />
|
<ButtonIcon icon={TrashIcon} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
testID={`feed-${feed.type}-togglePin`}
|
testID={`feed-${feed.type}-togglePin`}
|
||||||
label={isPinned ? _(msg`Unpin feed`) : _(msg`Pin feed`)}
|
label={_(msg`Pin feed`)}
|
||||||
onPress={onTogglePinned}
|
onPress={onTogglePinned}
|
||||||
size="small"
|
size="small"
|
||||||
color={isPinned ? 'primary_subtle' : 'secondary'}
|
color="secondary"
|
||||||
shape="square">
|
shape="square">
|
||||||
<ButtonIcon icon={PinIcon} />
|
<ButtonIcon icon={PinIcon} />
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion'
|
||||||
import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
|
import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
|
||||||
import {Contacts_Stroke2_Corner2_Rounded as ContactsIcon} from '#/components/icons/Contacts'
|
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 {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
|
||||||
import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
|
import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
|
||||||
import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller'
|
import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller'
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import {CreateListFromStarterPackDialog} from '#/components/dialogs/lists/Create
|
|||||||
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
|
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
|
||||||
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
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 {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
|
||||||
import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
|
import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {atoms as a, select, useTheme} from '#/alf'
|
|||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlusIcon} from '#/components/icons/CirclePlus'
|
import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlusIcon} from '#/components/icons/CirclePlus'
|
||||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
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 {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
|
||||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||||
import * as MediaPreview from '#/components/MediaPreview'
|
import * as MediaPreview from '#/components/MediaPreview'
|
||||||
|
|||||||
@@ -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 {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
|
||||||
import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
|
import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
|
||||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
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 {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
|
||||||
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
|
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
|
||||||
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
|
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
BulletList_Filled_Corner0_Rounded as ListFilled,
|
BulletList_Filled_Corner0_Rounded as ListFilled,
|
||||||
BulletList_Stroke2_Corner0_Rounded as List,
|
BulletList_Stroke2_Corner0_Rounded as List,
|
||||||
} from '#/components/icons/BulletList'
|
} 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 {EditBig_Stroke2_Corner0_Rounded as EditBig} from '#/components/icons/EditBig'
|
||||||
import {
|
import {
|
||||||
Hashtag_Filled_Corner0_Rounded as HashtagFilled,
|
Hashtag_Filled_Corner0_Rounded as HashtagFilled,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
|
|||||||
import {useTrendingConfig} from '#/state/service-config'
|
import {useTrendingConfig} from '#/state/service-config'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
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 {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {TrendingTopicLink} from '#/components/TrendingTopics'
|
import {TrendingTopicLink} from '#/components/TrendingTopics'
|
||||||
|
|||||||
Reference in New Issue
Block a user