Bluesky app fork with some witchin' additions 💫

[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>

+977 -96
+1
assets/icons/dotGrid2x3_stroke2_corner2_rounded.svg
··· 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>
+489
src/components/DraggableList/index.tsx
··· 1 + import {useLayoutEffect, useRef} from 'react' 2 + import {Gesture, GestureDetector} from 'react-native-gesture-handler' 3 + import Animated, { 4 + type AnimatedRef, 5 + measure, 6 + runOnJS, 7 + scrollTo, 8 + type SharedValue, 9 + useAnimatedRef, 10 + useAnimatedStyle, 11 + useFrameCallback, 12 + useSharedValue, 13 + withSpring, 14 + withTiming, 15 + } from 'react-native-reanimated' 16 + 17 + import {useHaptics} from '#/lib/haptics' 18 + import {atoms as a, useTheme, web} from '#/alf' 19 + import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid' 20 + import {IS_IOS} from '#/env' 21 + 22 + /** 23 + * Drag-to-reorder list. Items are absolutely positioned in a fixed-height 24 + * container and animated via Reanimated shared values on the UI thread. 25 + * 26 + * All positioning is driven by a `slots` map (key → index) and translateY 27 + * (no discrete `top` changes). On drag end the new slot assignment is 28 + * computed on the UI thread first, then React state is updated via runOnJS. 29 + * 30 + * See SortableList.web.tsx for the web implementation using pointer events. 31 + */ 32 + 33 + interface SortableListProps<T> { 34 + data: T[] 35 + keyExtractor: (item: T) => string 36 + renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode 37 + onReorder: (data: T[]) => void 38 + onDragStart?: () => void 39 + onDragEnd?: () => void 40 + /** Fixed row height used for position math. */ 41 + itemHeight: number 42 + /** Ref to the parent Animated.ScrollView for auto-scroll. */ 43 + scrollRef?: AnimatedRef<Animated.ScrollView> 44 + /** Scroll offset shared value from useScrollViewOffset. */ 45 + scrollOffset?: SharedValue<number> 46 + } 47 + 48 + const AUTO_SCROLL_THRESHOLD = 50 49 + const AUTO_SCROLL_SPEED = 4 50 + 51 + /** 52 + * Bundled into a single shared value so all fields update atomically 53 + * in one set() call on the UI thread. 54 + */ 55 + interface DragState { 56 + /** Maps each item key to its current slot index. */ 57 + slots: Record<string, number> 58 + /** Key of the item being dragged, or '' when idle. */ 59 + activeKey: string 60 + /** Slot the active item started in. */ 61 + dragStartSlot: number 62 + } 63 + 64 + export function SortableList<T>({ 65 + data, 66 + keyExtractor, 67 + renderItem, 68 + onReorder, 69 + onDragStart, 70 + onDragEnd, 71 + itemHeight, 72 + scrollRef, 73 + scrollOffset, 74 + }: SortableListProps<T>) { 75 + const t = useTheme() 76 + const state = useSharedValue<DragState>({ 77 + slots: Object.fromEntries(data.map((item, i) => [keyExtractor(item), i])), 78 + activeKey: '', 79 + dragStartSlot: -1, 80 + }) 81 + const dragY = useSharedValue(0) 82 + 83 + // Auto-scroll shared values 84 + const scrollCompensation = useSharedValue(0) 85 + const isGestureActive = useSharedValue(false) 86 + // We track scroll position ourselves because scrollOffset.get() lags 87 + // by one frame after scrollTo(), causing a feedback loop where the 88 + // frame callback keeps thinking the item is at the edge. 89 + const trackedScrollY = useSharedValue(0) 90 + 91 + // For measuring list position within scroll content 92 + const listRef = useAnimatedRef<Animated.View>() 93 + const listContentOffset = useSharedValue(0) 94 + const viewportHeight = useSharedValue(0) 95 + const measureDone = useSharedValue(false) 96 + 97 + // Sync slots when data changes externally (e.g. pin/unpin). 98 + // Skip after our own reorder — the worklet already set correct slots 99 + // on the UI thread, and a redundant JS-side set() would be wasteful. 100 + const skipNextSync = useRef(false) 101 + const currentKeys = data.map(item => keyExtractor(item)).join(',') 102 + useLayoutEffect(() => { 103 + if (skipNextSync.current) { 104 + skipNextSync.current = false 105 + return 106 + } 107 + const nextSlots: Record<string, number> = {} 108 + data.forEach((item, i) => { 109 + nextSlots[keyExtractor(item)] = i 110 + }) 111 + state.set({slots: nextSlots, activeKey: '', dragStartSlot: -1}) 112 + dragY.set(0) 113 + }, [currentKeys, data, keyExtractor, state, dragY]) 114 + 115 + const handleReorder = (sortedKeys: string[]) => { 116 + skipNextSync.current = true 117 + const byKey = new Map(data.map(item => [keyExtractor(item), item])) 118 + onReorder(sortedKeys.map(key => byKey.get(key)!)) 119 + onDragEnd?.() 120 + } 121 + 122 + // Auto-scroll: runs every frame while a gesture is active. 123 + useFrameCallback(() => { 124 + if (!isGestureActive.get()) return 125 + if (!scrollRef || !scrollOffset) return 126 + 127 + const s = state.get() 128 + if (s.activeKey === '') return 129 + 130 + // Measure list and scroll view on first frame of drag. 131 + // Use scrollOffset here (only once) since no lag has occurred yet. 132 + if (!measureDone.get()) { 133 + const scrollM = measure( 134 + scrollRef as unknown as AnimatedRef<Animated.View>, 135 + ) 136 + const listM = measure(listRef) 137 + if (!scrollM || !listM) return 138 + trackedScrollY.set(scrollOffset.get()) 139 + listContentOffset.set(listM.pageY - scrollM.pageY + trackedScrollY.get()) 140 + viewportHeight.set(scrollM.height) 141 + measureDone.set(true) 142 + } 143 + 144 + const startSlot = s.dragStartSlot 145 + const currentDragY = dragY.get() 146 + 147 + // Use trackedScrollY (not scrollOffset) to avoid the one-frame lag 148 + // after scrollTo() that causes a feedback loop. 149 + const scrollY = trackedScrollY.get() 150 + 151 + // Item position relative to scroll viewport top. 152 + const itemContentY = 153 + listContentOffset.get() + startSlot * itemHeight + currentDragY 154 + const itemViewportY = itemContentY - scrollY 155 + const itemBottomViewportY = itemViewportY + itemHeight 156 + 157 + let scrollDelta = 0 158 + if (itemViewportY < AUTO_SCROLL_THRESHOLD) { 159 + scrollDelta = -AUTO_SCROLL_SPEED 160 + } else if ( 161 + itemBottomViewportY > 162 + viewportHeight.get() - AUTO_SCROLL_THRESHOLD 163 + ) { 164 + scrollDelta = AUTO_SCROLL_SPEED 165 + } 166 + 167 + if (scrollDelta === 0) return 168 + 169 + // Don't scroll if the item is already at a list boundary. 170 + const effectiveSlotPos = 171 + (startSlot * itemHeight + currentDragY) / itemHeight 172 + if (scrollDelta < 0 && effectiveSlotPos <= 0) return 173 + if (scrollDelta > 0 && effectiveSlotPos >= data.length - 1) return 174 + 175 + // Don't scroll past the top. 176 + if (scrollDelta < 0 && scrollY <= 0) return 177 + 178 + const newScrollY = Math.max(0, scrollY + scrollDelta) 179 + scrollTo(scrollRef, 0, newScrollY, false) 180 + trackedScrollY.set(newScrollY) 181 + scrollCompensation.set(scrollCompensation.get() + (newScrollY - scrollY)) 182 + }) 183 + 184 + // Render in stable key order so React never reorders native views. 185 + // On Android, native ViewGroup child reordering causes a visual flash. 186 + const sortedData = [...data].sort((a, b) => { 187 + const ka = keyExtractor(a) 188 + const kb = keyExtractor(b) 189 + return ka < kb ? -1 : ka > kb ? 1 : 0 190 + }) 191 + 192 + return ( 193 + <Animated.View 194 + ref={listRef} 195 + style={[{height: data.length * itemHeight}, t.atoms.bg_contrast_25]}> 196 + {sortedData.map(item => { 197 + const key = keyExtractor(item) 198 + return ( 199 + <SortableItem 200 + key={key} 201 + item={item} 202 + itemKey={key} 203 + itemCount={data.length} 204 + itemHeight={itemHeight} 205 + state={state} 206 + dragY={dragY} 207 + scrollCompensation={scrollCompensation} 208 + isGestureActive={isGestureActive} 209 + measureDone={measureDone} 210 + renderItem={renderItem} 211 + onCommitReorder={handleReorder} 212 + onDragStart={onDragStart} 213 + onDragEnd={onDragEnd} 214 + /> 215 + ) 216 + })} 217 + </Animated.View> 218 + ) 219 + } 220 + 221 + function SortableItem<T>({ 222 + item, 223 + itemKey, 224 + itemCount, 225 + itemHeight, 226 + state, 227 + dragY, 228 + scrollCompensation, 229 + isGestureActive, 230 + measureDone, 231 + renderItem, 232 + onCommitReorder, 233 + onDragStart, 234 + onDragEnd, 235 + }: { 236 + item: T 237 + itemKey: string 238 + itemCount: number 239 + itemHeight: number 240 + state: Animated.SharedValue<DragState> 241 + dragY: Animated.SharedValue<number> 242 + scrollCompensation: SharedValue<number> 243 + isGestureActive: SharedValue<boolean> 244 + measureDone: SharedValue<boolean> 245 + renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode 246 + onCommitReorder: (sortedKeys: string[]) => void 247 + onDragStart?: () => void 248 + onDragEnd?: () => void 249 + }) { 250 + const t = useTheme() 251 + const playHaptic = useHaptics() 252 + 253 + const lastHapticSlot = useSharedValue(-1) 254 + 255 + const gesture = Gesture.Pan() 256 + .onStart(() => { 257 + 'worklet' 258 + const s = state.get() 259 + const mySlot = s.slots[itemKey] 260 + state.set({...s, activeKey: itemKey, dragStartSlot: mySlot}) 261 + dragY.set(0) 262 + scrollCompensation.set(0) 263 + isGestureActive.set(true) 264 + measureDone.set(false) 265 + lastHapticSlot.set(mySlot) 266 + if (onDragStart) { 267 + runOnJS(onDragStart)() 268 + } 269 + runOnJS(playHaptic)() 270 + }) 271 + .onChange(e => { 272 + 'worklet' 273 + const startSlot = state.get().dragStartSlot 274 + const minY = -startSlot * itemHeight 275 + const maxY = (itemCount - 1 - startSlot) * itemHeight 276 + // Include scroll compensation so the item tracks with auto-scroll. 277 + const effectiveY = e.translationY + scrollCompensation.get() 278 + const clampedY = Math.max(minY, Math.min(effectiveY, maxY)) 279 + dragY.set(clampedY) 280 + 281 + const currentSlot = Math.round( 282 + (startSlot * itemHeight + clampedY) / itemHeight, 283 + ) 284 + const clampedSlot = Math.max(0, Math.min(currentSlot, itemCount - 1)) 285 + if (IS_IOS && clampedSlot !== lastHapticSlot.get()) { 286 + lastHapticSlot.set(clampedSlot) 287 + runOnJS(playHaptic)('Light') 288 + } 289 + }) 290 + .onEnd(() => { 291 + 'worklet' 292 + // Stop auto-scroll BEFORE the snap animation. 293 + isGestureActive.set(false) 294 + const startSlot = state.get().dragStartSlot 295 + const rawNewSlot = Math.round( 296 + (startSlot * itemHeight + dragY.get()) / itemHeight, 297 + ) 298 + const newSlot = Math.max(0, Math.min(rawNewSlot, itemCount - 1)) 299 + const snapOffset = (newSlot - startSlot) * itemHeight 300 + 301 + // Animate to the target slot, then commit. 302 + dragY.set( 303 + withTiming(snapOffset, {duration: 200}, finished => { 304 + if (finished) { 305 + if (newSlot !== startSlot) { 306 + // Compute new slots on the UI thread so animated styles 307 + // reflect final positions before React re-renders. 308 + const cur = state.get() 309 + const sorted: string[] = new Array(itemCount) 310 + for (const key in cur.slots) { 311 + sorted[cur.slots[key]] = key 312 + } 313 + const movedKey = sorted[startSlot] 314 + sorted.splice(startSlot, 1) 315 + sorted.splice(newSlot, 0, movedKey) 316 + 317 + const nextSlots: Record<string, number> = {} 318 + for (let i = 0; i < sorted.length; i++) { 319 + nextSlots[sorted[i]] = i 320 + } 321 + 322 + state.set({ 323 + slots: nextSlots, 324 + activeKey: '', 325 + dragStartSlot: -1, 326 + }) 327 + dragY.set(0) 328 + runOnJS(onCommitReorder)(sorted) 329 + } else { 330 + const s = state.get() 331 + state.set({...s, activeKey: '', dragStartSlot: -1}) 332 + dragY.set(0) 333 + if (onDragEnd) { 334 + runOnJS(onDragEnd)() 335 + } 336 + } 337 + } 338 + }), 339 + ) 340 + }) 341 + // Reset if the gesture is cancelled without onEnd firing. 342 + .onFinalize(() => { 343 + 'worklet' 344 + isGestureActive.set(false) 345 + if (state.get().activeKey === itemKey && dragY.get() === 0) { 346 + const s = state.get() 347 + state.set({...s, activeKey: '', dragStartSlot: -1}) 348 + if (onDragEnd) { 349 + runOnJS(onDragEnd)() 350 + } 351 + } 352 + }) 353 + 354 + // All vertical positioning is via translateY (no `top`). This avoids 355 + // discrete jumps when slots change — Reanimated smoothly animates from 356 + // the current translateY to the new target on every state transition. 357 + // On first mount we skip the animation so items appear instantly. 358 + const isFirstRender = useSharedValue(true) 359 + 360 + const animatedStyle = useAnimatedStyle(() => { 361 + const s = state.get() 362 + const mySlot = s.slots[itemKey] 363 + if (mySlot === undefined) { 364 + return {} 365 + } 366 + const baseY = mySlot * itemHeight 367 + 368 + // Active item: follow the finger with a slight scale-up and shadow. 369 + if (s.activeKey === itemKey) { 370 + return { 371 + transform: [ 372 + {translateY: s.dragStartSlot * itemHeight + dragY.get()}, 373 + {scale: withSpring(1.03)}, 374 + ], 375 + zIndex: 999, 376 + ...(IS_IOS 377 + ? { 378 + shadowColor: '#000', 379 + shadowOffset: {width: 0, height: 1}, 380 + shadowOpacity: withSpring(0.08), 381 + shadowRadius: withSpring(4), 382 + } 383 + : { 384 + elevation: withSpring(3), 385 + }), 386 + } 387 + } 388 + 389 + // Reset for non-active states. Without this, shadow props 390 + // set during dragging linger on the native view. 391 + const inactive = { 392 + ...(IS_IOS 393 + ? { 394 + shadowOpacity: withSpring(0), 395 + shadowRadius: withSpring(0), 396 + } 397 + : { 398 + elevation: withSpring(0), 399 + }), 400 + } 401 + 402 + // Another item is being dragged — shift to make room. 403 + if (s.activeKey !== '') { 404 + isFirstRender.set(false) 405 + const currentDragPos = Math.round( 406 + (s.dragStartSlot * itemHeight + dragY.get()) / itemHeight, 407 + ) 408 + const clampedPos = Math.max(0, Math.min(currentDragPos, itemCount - 1)) 409 + 410 + let offset = 0 411 + if ( 412 + s.dragStartSlot < clampedPos && 413 + mySlot > s.dragStartSlot && 414 + mySlot <= clampedPos 415 + ) { 416 + offset = -itemHeight 417 + } else if ( 418 + s.dragStartSlot > clampedPos && 419 + mySlot < s.dragStartSlot && 420 + mySlot >= clampedPos 421 + ) { 422 + offset = itemHeight 423 + } 424 + 425 + return { 426 + transform: [ 427 + {translateY: withTiming(baseY + offset, {duration: 200})}, 428 + {scale: withSpring(1)}, 429 + ], 430 + zIndex: 0, 431 + ...inactive, 432 + } 433 + } 434 + 435 + // Idle: sit at our slot. On first render use a direct value so items 436 + // don't animate from y=0. After any drag, use withTiming so the 437 + // shift→idle transition is smooth (no discrete jump). 438 + if (isFirstRender.get()) { 439 + isFirstRender.set(false) 440 + return { 441 + transform: [{translateY: baseY}, {scale: 1}], 442 + zIndex: 0, 443 + ...inactive, 444 + } 445 + } 446 + 447 + return { 448 + transform: [{translateY: withTiming(baseY, {duration: 200})}, {scale: 1}], 449 + zIndex: 0, 450 + ...inactive, 451 + } 452 + }) 453 + 454 + const dragHandle = ( 455 + <GestureDetector gesture={gesture}> 456 + <Animated.View 457 + style={[ 458 + a.justify_center, 459 + a.align_center, 460 + a.px_sm, 461 + a.py_md, 462 + web({cursor: 'grab'}), 463 + ]} 464 + hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}> 465 + <GripIcon 466 + size="lg" 467 + fill={t.atoms.text_contrast_medium.color} 468 + style={web({pointerEvents: 'none'})} 469 + /> 470 + </Animated.View> 471 + </GestureDetector> 472 + ) 473 + 474 + return ( 475 + <Animated.View 476 + style={[ 477 + { 478 + position: 'absolute', 479 + top: 0, 480 + left: 0, 481 + right: 0, 482 + height: itemHeight, 483 + }, 484 + animatedStyle, 485 + ]}> 486 + {renderItem(item, dragHandle)} 487 + </Animated.View> 488 + ) 489 + }
+168
src/components/DraggableList/index.web.tsx
··· 1 + import {useState} from 'react' 2 + import {View} from 'react-native' 3 + 4 + import {useTheme} from '#/alf' 5 + import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid' 6 + 7 + /** 8 + * Web implementation of SortableList using pointer events. 9 + * See SortableList.tsx for the native version using gesture-handler + Reanimated. 10 + */ 11 + 12 + interface SortableListProps<T> { 13 + data: T[] 14 + keyExtractor: (item: T) => string 15 + renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode 16 + onReorder: (data: T[]) => void 17 + onDragStart?: () => void 18 + onDragEnd?: () => void 19 + /** Fixed row height used for position math. */ 20 + itemHeight: number 21 + } 22 + 23 + export function SortableList<T>({ 24 + data, 25 + keyExtractor, 26 + renderItem, 27 + onReorder, 28 + onDragStart, 29 + onDragEnd, 30 + itemHeight, 31 + }: SortableListProps<T>) { 32 + const t = useTheme() 33 + const [dragState, setDragState] = useState<{ 34 + activeIndex: number 35 + currentY: number 36 + startY: number 37 + } | null>(null) 38 + 39 + const getNewPosition = (state: { 40 + activeIndex: number 41 + currentY: number 42 + startY: number 43 + }) => { 44 + const translationY = state.currentY - state.startY 45 + const rawNewPos = Math.round( 46 + (state.activeIndex * itemHeight + translationY) / itemHeight, 47 + ) 48 + return Math.max(0, Math.min(rawNewPos, data.length - 1)) 49 + } 50 + 51 + const handlePointerMove = (e: React.PointerEvent) => { 52 + if (!dragState) return 53 + e.preventDefault() 54 + setDragState(prev => (prev ? {...prev, currentY: e.clientY} : null)) 55 + } 56 + 57 + const handlePointerUp = () => { 58 + if (!dragState) return 59 + const newPos = getNewPosition(dragState) 60 + if (newPos !== dragState.activeIndex) { 61 + const next = [...data] 62 + const [moved] = next.splice(dragState.activeIndex, 1) 63 + next.splice(newPos, 0, moved) 64 + onReorder(next) 65 + } 66 + setDragState(null) 67 + onDragEnd?.() 68 + } 69 + 70 + const handlePointerDown = (e: React.PointerEvent, index: number) => { 71 + e.preventDefault() 72 + ;(e.target as HTMLElement).setPointerCapture(e.pointerId) 73 + setDragState({activeIndex: index, currentY: e.clientY, startY: e.clientY}) 74 + onDragStart?.() 75 + } 76 + 77 + const newPos = dragState ? getNewPosition(dragState) : -1 78 + 79 + return ( 80 + <View 81 + style={[ 82 + {height: data.length * itemHeight, position: 'relative'}, 83 + t.atoms.bg_contrast_25, 84 + ]} 85 + // @ts-expect-error web-only pointer events 86 + onPointerMove={handlePointerMove} 87 + onPointerUp={handlePointerUp} 88 + onPointerCancel={handlePointerUp}> 89 + {data.map((item, index) => { 90 + const isActive = dragState?.activeIndex === index 91 + 92 + // Clamp translation so the item stays within list bounds. 93 + const rawTranslationY = isActive 94 + ? dragState.currentY - dragState.startY 95 + : 0 96 + const translationY = isActive 97 + ? Math.max( 98 + -index * itemHeight, 99 + Math.min(rawTranslationY, (data.length - 1 - index) * itemHeight), 100 + ) 101 + : 0 102 + 103 + // Non-dragged items shift to make room for the dragged item. 104 + let offset = 0 105 + if (dragState && !isActive) { 106 + const orig = dragState.activeIndex 107 + if (orig < newPos && index > orig && index <= newPos) { 108 + offset = -itemHeight 109 + } else if (orig > newPos && index < orig && index >= newPos) { 110 + offset = itemHeight 111 + } 112 + } 113 + 114 + const dragHandle = ( 115 + <div 116 + onPointerDown={(e: React.PointerEvent<HTMLDivElement>) => 117 + handlePointerDown(e, index) 118 + } 119 + style={{ 120 + display: 'flex', 121 + justifyContent: 'center', 122 + alignItems: 'center', 123 + paddingLeft: 8, 124 + paddingRight: 8, 125 + paddingTop: 12, 126 + paddingBottom: 12, 127 + cursor: isActive ? 'grabbing' : 'grab', 128 + touchAction: 'none', 129 + userSelect: 'none', 130 + }}> 131 + <GripIcon 132 + size="lg" 133 + fill={t.atoms.text_contrast_medium.color} 134 + style={{pointerEvents: 'none'} as any} 135 + /> 136 + </div> 137 + ) 138 + 139 + return ( 140 + <View 141 + key={keyExtractor(item)} 142 + style={[ 143 + { 144 + position: 'absolute', 145 + top: index * itemHeight, 146 + left: 0, 147 + right: 0, 148 + height: itemHeight, 149 + transform: [{translateY: isActive ? translationY : offset}], 150 + scale: isActive ? 1.03 : 1, 151 + zIndex: isActive ? 999 : 0, 152 + boxShadow: isActive ? '0 2px 12px rgba(0,0,0,0.06)' : 'none', 153 + // Animate scale/shadow on pickup, and transform for 154 + // non-dragged items shifting into place. 155 + transition: isActive 156 + ? 'box-shadow 200ms ease, scale 200ms ease' 157 + : dragState 158 + ? 'transform 200ms ease' 159 + : 'none', 160 + } as any, 161 + ]}> 162 + {renderItem(item, dragHandle)} 163 + </View> 164 + ) 165 + })} 166 + </View> 167 + ) 168 + }
+1 -1
src/components/PostControls/PostMenu/index.tsx
··· 11 11 12 12 import {type Shadow} from '#/state/cache/post-shadow' 13 13 import {EventStopper} from '#/view/com/util/EventStopper' 14 - import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 14 + import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 15 15 import {useMenuControl} from '#/components/Menu' 16 16 import * as Menu from '#/components/Menu' 17 17 import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
+1 -1
src/components/dms/ActionsWrapper.web.tsx
··· 9 9 import * as Toast from '#/view/com/util/Toast' 10 10 import {atoms as a, useTheme} from '#/alf' 11 11 import {MessageContextMenu} from '#/components/dms/MessageContextMenu' 12 - import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' 12 + import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' 13 13 import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' 14 14 import {EmojiReactionPicker} from './EmojiReactionPicker' 15 15 import {hasReachedReactionLimit} from './util'
+1 -1
src/components/dms/ConvoMenu.tsx
··· 24 24 import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt' 25 25 import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' 26 26 import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' 27 - import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 27 + import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 28 28 import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 29 29 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 30 30 import {
+1 -1
src/components/dms/EmojiReactionPicker.web.tsx
··· 10 10 import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker' 11 11 import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji' 12 12 import {atoms as a, flatten, useTheme} from '#/alf' 13 - import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' 13 + import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' 14 14 import * as Menu from '#/components/Menu' 15 15 import {type TriggerProps} from '#/components/Menu/types' 16 16 import {Text} from '#/components/Typography'
+5 -1
src/components/icons/DotGrid.tsx
··· 1 1 import {createSinglePathSVG} from './TEMPLATE' 2 2 3 - export const DotGrid_Stroke2_Corner0_Rounded = createSinglePathSVG({ 3 + export const DotGrid3x1_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 4 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', 5 5 }) 6 + 7 + export const DotGrid2x3_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 + 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', 9 + })
+1 -1
src/screens/Profile/components/ProfileFeedHeader.tsx
··· 30 30 import {useRichText} from '#/components/hooks/useRichText' 31 31 import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 32 32 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 33 - import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 33 + import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 34 34 import { 35 35 Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, 36 36 Heart2_Stroke2_Corner0_Rounded as Heart,
+1 -1
src/screens/ProfileList/components/MoreOptionsMenu.tsx
··· 20 20 import {CreateOrEditListDialog} from '#/components/dialogs/lists/CreateOrEditListDialog' 21 21 import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox' 22 22 import {ChainLink_Stroke2_Corner0_Rounded as ChainLink} from '#/components/icons/ChainLink' 23 - import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' 23 + import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' 24 24 import {PencilLine_Stroke2_Corner0_Rounded as PencilLineIcon} from '#/components/icons/Pencil' 25 25 import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheckIcon} from '#/components/icons/Person' 26 26 import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
+302 -83
src/screens/SavedFeeds.tsx
··· 1 1 import {useCallback, useState} from 'react' 2 2 import {View} from 'react-native' 3 - import Animated, {LinearTransition} from 'react-native-reanimated' 3 + import type Animated from 'react-native-reanimated' 4 + import {useAnimatedRef, useScrollViewOffset} from 'react-native-reanimated' 4 5 import {type AppBskyActorDefs} from '@atproto/api' 5 6 import {TID} from '@atproto/common-web' 6 7 import {msg} from '@lingui/core/macro' ··· 16 17 type NavigationProp, 17 18 } from '#/lib/routes/types' 18 19 import {logger} from '#/logger' 20 + import {useA11y} from '#/state/a11y' 19 21 import { 20 22 useOverwriteSavedFeedsMutation, 21 23 usePreferencesQuery, ··· 29 31 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 30 32 import {Admonition} from '#/components/Admonition' 31 33 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 34 + import {SortableList} from '#/components/DraggableList' 32 35 import { 33 36 ArrowBottom_Stroke2_Corner0_Rounded as ArrowDownIcon, 34 37 ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon, ··· 45 48 type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> 46 49 export function SavedFeeds({}: Props) { 47 50 const {data: preferences} = usePreferencesQuery() 51 + const {screenReaderEnabled} = useA11y() 48 52 if (!preferences) { 49 53 return <View /> 50 54 } 55 + if (screenReaderEnabled) { 56 + return <SavedFeedsA11y preferences={preferences} /> 57 + } 51 58 return <SavedFeedsInner preferences={preferences} /> 52 59 } 53 60 ··· 63 70 const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = 64 71 useOverwriteSavedFeedsMutation() 65 72 const navigation = useNavigation<NavigationProp>() 73 + const scrollRef = useAnimatedRef<Animated.ScrollView>() 74 + const scrollOffset = useScrollViewOffset(scrollRef) 66 75 67 76 /* 68 77 * Use optimistic data if exists and no error, otherwise fallback to remote ··· 77 86 const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 78 87 const noFollowingFeed = 79 88 currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType 89 + const [isDragging, setIsDragging] = useState(false) 80 90 81 91 useFocusEffect( 82 92 useCallback(() => { ··· 122 132 </Button> 123 133 </Layout.Header.Outer> 124 134 125 - <Layout.Content> 135 + <Layout.Content ref={scrollRef} scrollEnabled={!isDragging}> 126 136 {noSavedFeedsOfAnyType && ( 127 137 <View style={[t.atoms.border_contrast_low, a.border_b]}> 128 138 <NoSavedFeedsOfAnyType ··· 150 160 </Admonition> 151 161 </View> 152 162 ) : ( 153 - pinnedFeeds.map(f => ( 154 - <ListItem 155 - key={f.id} 156 - feed={f} 157 - isPinned 158 - currentFeeds={currentFeeds} 159 - setCurrentFeeds={setCurrentFeeds} 160 - preferences={preferences} 161 - /> 162 - )) 163 + <SortableList 164 + data={pinnedFeeds} 165 + keyExtractor={f => f.id} 166 + itemHeight={68} 167 + scrollRef={scrollRef} 168 + scrollOffset={scrollOffset} 169 + onDragStart={() => setIsDragging(true)} 170 + onDragEnd={() => setIsDragging(false)} 171 + onReorder={reordered => { 172 + setCurrentFeeds([...reordered, ...unpinnedFeeds]) 173 + }} 174 + renderItem={(feed, dragHandle) => ( 175 + <PinnedFeedItem 176 + feed={feed} 177 + currentFeeds={currentFeeds} 178 + setCurrentFeeds={setCurrentFeeds} 179 + dragHandle={dragHandle} 180 + /> 181 + )} 182 + /> 163 183 ) 164 184 ) : ( 165 185 <View style={[a.w_full, a.py_2xl, a.align_center]}> ··· 193 213 </View> 194 214 ) : ( 195 215 unpinnedFeeds.map(f => ( 196 - <ListItem 216 + <UnpinnedFeedItem 197 217 key={f.id} 198 218 feed={f} 199 - isPinned={false} 200 219 currentFeeds={currentFeeds} 201 220 setCurrentFeeds={setCurrentFeeds} 202 - preferences={preferences} 203 221 /> 204 222 )) 205 223 ) ··· 231 249 ) 232 250 } 233 251 234 - function ListItem({ 252 + function SavedFeedsA11y({ 253 + preferences, 254 + }: { 255 + preferences: UsePreferencesQueryResponse 256 + }) { 257 + const t = useTheme() 258 + const {_} = useLingui() 259 + const {gtMobile} = useBreakpoints() 260 + const setMinimalShellMode = useSetMinimalShellMode() 261 + const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = 262 + useOverwriteSavedFeedsMutation() 263 + const navigation = useNavigation<NavigationProp>() 264 + 265 + const [currentFeeds, setCurrentFeeds] = useState( 266 + () => preferences.savedFeeds || [], 267 + ) 268 + const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds 269 + const pinnedFeeds = currentFeeds.filter(f => f.pinned) 270 + const unpinnedFeeds = currentFeeds.filter(f => !f.pinned) 271 + const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 272 + const noFollowingFeed = 273 + currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType 274 + 275 + useFocusEffect( 276 + useCallback(() => { 277 + setMinimalShellMode(false) 278 + }, [setMinimalShellMode]), 279 + ) 280 + 281 + const onSaveChanges = async () => { 282 + try { 283 + await overwriteSavedFeeds(currentFeeds) 284 + Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) 285 + if (navigation.canGoBack()) { 286 + navigation.goBack() 287 + } else { 288 + navigation.navigate('Feeds') 289 + } 290 + } catch (e) { 291 + Toast.show(_(msg`There was an issue contacting the server`), 'xmark') 292 + logger.error('Failed to toggle pinned feed', {message: e}) 293 + } 294 + } 295 + 296 + const onMoveUp = (index: number) => { 297 + const pinned = [...pinnedFeeds] 298 + ;[pinned[index - 1], pinned[index]] = [pinned[index], pinned[index - 1]] 299 + setCurrentFeeds([...pinned, ...unpinnedFeeds]) 300 + } 301 + 302 + const onMoveDown = (index: number) => { 303 + const pinned = [...pinnedFeeds] 304 + ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] 305 + setCurrentFeeds([...pinned, ...unpinnedFeeds]) 306 + } 307 + 308 + return ( 309 + <Layout.Screen> 310 + <Layout.Header.Outer> 311 + <Layout.Header.BackButton /> 312 + <Layout.Header.Content align="left"> 313 + <Layout.Header.TitleText> 314 + <Trans>Feeds</Trans> 315 + </Layout.Header.TitleText> 316 + </Layout.Header.Content> 317 + <Button 318 + testID="saveChangesBtn" 319 + size="small" 320 + color={hasUnsavedChanges ? 'primary' : 'secondary'} 321 + onPress={onSaveChanges} 322 + label={_(msg`Save changes`)} 323 + disabled={isOverwritePending || !hasUnsavedChanges}> 324 + <ButtonIcon icon={isOverwritePending ? Loader : SaveIcon} /> 325 + <ButtonText> 326 + {gtMobile ? <Trans>Save changes</Trans> : <Trans>Save</Trans>} 327 + </ButtonText> 328 + </Button> 329 + </Layout.Header.Outer> 330 + 331 + <Layout.Content> 332 + {noSavedFeedsOfAnyType && ( 333 + <View style={[t.atoms.border_contrast_low, a.border_b]}> 334 + <NoSavedFeedsOfAnyType 335 + onAddRecommendedFeeds={() => 336 + setCurrentFeeds( 337 + RECOMMENDED_SAVED_FEEDS.map(f => ({ 338 + ...f, 339 + id: TID.nextStr(), 340 + })), 341 + ) 342 + } 343 + /> 344 + </View> 345 + )} 346 + 347 + <SectionHeaderText> 348 + <Trans>Pinned Feeds</Trans> 349 + </SectionHeaderText> 350 + 351 + {!pinnedFeeds.length ? ( 352 + <View style={[a.flex_1, a.p_lg]}> 353 + <Admonition type="info"> 354 + <Trans>You don't have any pinned feeds.</Trans> 355 + </Admonition> 356 + </View> 357 + ) : ( 358 + pinnedFeeds.map((feed, i) => ( 359 + <PinnedFeedItem 360 + key={feed.id} 361 + feed={feed} 362 + currentFeeds={currentFeeds} 363 + setCurrentFeeds={setCurrentFeeds} 364 + index={i} 365 + total={pinnedFeeds.length} 366 + onMoveUp={() => onMoveUp(i)} 367 + onMoveDown={() => onMoveDown(i)} 368 + /> 369 + )) 370 + )} 371 + 372 + {noFollowingFeed && ( 373 + <View style={[t.atoms.border_contrast_low, a.border_b]}> 374 + <NoFollowingFeed 375 + onAddFeed={() => 376 + setCurrentFeeds(feeds => [ 377 + ...feeds, 378 + {...TIMELINE_SAVED_FEED, id: TID.next().toString()}, 379 + ]) 380 + } 381 + /> 382 + </View> 383 + )} 384 + 385 + <SectionHeaderText> 386 + <Trans>Saved Feeds</Trans> 387 + </SectionHeaderText> 388 + 389 + {!unpinnedFeeds.length ? ( 390 + <View style={[a.flex_1, a.p_lg]}> 391 + <Admonition type="info"> 392 + <Trans>You don't have any saved feeds.</Trans> 393 + </Admonition> 394 + </View> 395 + ) : ( 396 + unpinnedFeeds.map(f => ( 397 + <UnpinnedFeedItem 398 + key={f.id} 399 + feed={f} 400 + currentFeeds={currentFeeds} 401 + setCurrentFeeds={setCurrentFeeds} 402 + /> 403 + )) 404 + )} 405 + 406 + <View style={[a.px_lg, a.py_xl]}> 407 + <Text 408 + style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}> 409 + <Trans> 410 + Feeds are custom algorithms that users build with a little coding 411 + expertise.{' '} 412 + <InlineLinkText 413 + to="https://github.com/bluesky-social/feed-generator" 414 + label={_(msg`See this guide`)} 415 + disableMismatchWarning 416 + style={[a.leading_snug]}> 417 + See this guide 418 + </InlineLinkText>{' '} 419 + for more information. 420 + </Trans> 421 + </Text> 422 + </View> 423 + </Layout.Content> 424 + </Layout.Screen> 425 + ) 426 + } 427 + 428 + function PinnedFeedItem({ 235 429 feed, 236 - isPinned, 237 430 currentFeeds, 238 431 setCurrentFeeds, 432 + dragHandle, 433 + index, 434 + total, 435 + onMoveUp, 436 + onMoveDown, 239 437 }: { 240 438 feed: AppBskyActorDefs.SavedFeed 241 - isPinned: boolean 242 439 currentFeeds: AppBskyActorDefs.SavedFeed[] 243 - setCurrentFeeds: React.Dispatch<AppBskyActorDefs.SavedFeed[]> 244 - preferences: UsePreferencesQueryResponse 440 + setCurrentFeeds: React.Dispatch< 441 + React.SetStateAction<AppBskyActorDefs.SavedFeed[]> 442 + > 443 + dragHandle?: React.ReactNode 444 + index?: number 445 + total?: number 446 + onMoveUp?: () => void 447 + onMoveDown?: () => void 245 448 }) { 246 449 const {_} = useLingui() 247 450 const t = useTheme() 248 451 const playHaptic = useHaptics() 249 452 const feedUri = feed.value 250 453 251 - const onTogglePinned = async () => { 454 + const onTogglePinned = () => { 252 455 playHaptic() 253 456 setCurrentFeeds( 254 457 currentFeeds.map(f => ··· 257 460 ) 258 461 } 259 462 260 - const onPressUp = async () => { 261 - if (!isPinned) return 262 - 263 - const nextFeeds = currentFeeds.slice() 264 - const ids = currentFeeds.map(f => f.id) 265 - const index = ids.indexOf(feed.id) 266 - const nextIndex = index - 1 267 - 268 - if (index === -1 || index === 0) return 269 - ;[nextFeeds[index], nextFeeds[nextIndex]] = [ 270 - nextFeeds[nextIndex], 271 - nextFeeds[index], 272 - ] 273 - 274 - setCurrentFeeds(nextFeeds) 275 - } 276 - 277 - const onPressDown = async () => { 278 - if (!isPinned) return 279 - 280 - const nextFeeds = currentFeeds.slice() 281 - const ids = currentFeeds.map(f => f.id) 282 - const index = ids.indexOf(feed.id) 283 - const nextIndex = index + 1 284 - 285 - if (index === -1 || index >= nextFeeds.filter(f => f.pinned).length - 1) 286 - return 287 - ;[nextFeeds[index], nextFeeds[nextIndex]] = [ 288 - nextFeeds[nextIndex], 289 - nextFeeds[index], 290 - ] 291 - 292 - setCurrentFeeds(nextFeeds) 293 - } 294 - 295 - const onPressRemove = async () => { 296 - playHaptic() 297 - setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) 298 - } 299 - 300 463 return ( 301 - <Animated.View 302 - style={[a.flex_row, a.border_b, t.atoms.border_contrast_low]} 303 - layout={LinearTransition.duration(100)}> 464 + <View style={[a.flex_row, t.atoms.bg]}> 304 465 {feed.type === 'timeline' ? ( 305 466 <FollowingFeedCard /> 306 467 ) : ( 307 468 <FeedSourceCard 308 - key={feedUri} 309 469 feedUri={feedUri} 310 - style={[isPinned && a.pr_sm]} 470 + style={[a.pr_sm]} 311 471 showMinimalPlaceholder 312 472 hideTopBorder={true} 313 473 /> 314 474 )} 315 - <View style={[a.pr_lg, a.flex_row, a.align_center, a.gap_sm]}> 316 - {isPinned ? ( 475 + <View style={[a.pr_sm, a.flex_row, a.align_center, a.gap_sm]}> 476 + <Button 477 + testID={`feed-${feed.type}-togglePin`} 478 + label={_(msg`Unpin feed`)} 479 + onPress={onTogglePinned} 480 + size="small" 481 + color="primary_subtle" 482 + shape="square"> 483 + <ButtonIcon icon={PinIcon} /> 484 + </Button> 485 + {onMoveUp !== undefined ? ( 317 486 <> 318 487 <Button 319 488 testID={`feed-${feed.type}-moveUp`} 320 489 label={_(msg`Move feed up`)} 321 - onPress={onPressUp} 490 + onPress={onMoveUp} 491 + disabled={index === 0} 322 492 size="small" 323 493 color="secondary" 324 494 shape="square"> ··· 327 497 <Button 328 498 testID={`feed-${feed.type}-moveDown`} 329 499 label={_(msg`Move feed down`)} 330 - onPress={onPressDown} 500 + onPress={onMoveDown} 501 + disabled={index === total! - 1} 331 502 size="small" 332 503 color="secondary" 333 504 shape="square"> ··· 335 506 </Button> 336 507 </> 337 508 ) : ( 338 - <Button 339 - testID={`feed-${feedUri}-toggleSave`} 340 - label={_(msg`Remove from my feeds`)} 341 - onPress={onPressRemove} 342 - size="small" 343 - color="secondary" 344 - variant="ghost" 345 - shape="square"> 346 - <ButtonIcon icon={TrashIcon} /> 347 - </Button> 509 + dragHandle 348 510 )} 511 + </View> 512 + </View> 513 + ) 514 + } 515 + 516 + function UnpinnedFeedItem({ 517 + feed, 518 + currentFeeds, 519 + setCurrentFeeds, 520 + }: { 521 + feed: AppBskyActorDefs.SavedFeed 522 + currentFeeds: AppBskyActorDefs.SavedFeed[] 523 + setCurrentFeeds: React.Dispatch< 524 + React.SetStateAction<AppBskyActorDefs.SavedFeed[]> 525 + > 526 + }) { 527 + const {_} = useLingui() 528 + const t = useTheme() 529 + const playHaptic = useHaptics() 530 + const feedUri = feed.value 531 + 532 + const onTogglePinned = () => { 533 + playHaptic() 534 + setCurrentFeeds( 535 + currentFeeds.map(f => 536 + f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, 537 + ), 538 + ) 539 + } 540 + 541 + const onPressRemove = () => { 542 + playHaptic() 543 + setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) 544 + } 545 + 546 + return ( 547 + <View style={[a.flex_row, a.border_b, t.atoms.border_contrast_low]}> 548 + {feed.type === 'timeline' ? ( 549 + <FollowingFeedCard /> 550 + ) : ( 551 + <FeedSourceCard 552 + feedUri={feedUri} 553 + showMinimalPlaceholder 554 + hideTopBorder={true} 555 + /> 556 + )} 557 + <View style={[a.pr_lg, a.flex_row, a.align_center, a.gap_sm]}> 558 + <Button 559 + testID={`feed-${feedUri}-toggleSave`} 560 + label={_(msg`Remove from my feeds`)} 561 + onPress={onPressRemove} 562 + size="small" 563 + color="secondary" 564 + variant="ghost" 565 + shape="square"> 566 + <ButtonIcon icon={TrashIcon} /> 567 + </Button> 349 568 <Button 350 569 testID={`feed-${feed.type}-togglePin`} 351 - label={isPinned ? _(msg`Unpin feed`) : _(msg`Pin feed`)} 570 + label={_(msg`Pin feed`)} 352 571 onPress={onTogglePinned} 353 572 size="small" 354 - color={isPinned ? 'primary_subtle' : 'secondary'} 573 + color="secondary" 355 574 shape="square"> 356 575 <ButtonIcon icon={PinIcon} /> 357 576 </Button> 358 577 </View> 359 - </Animated.View> 578 + </View> 360 579 ) 361 580 } 362 581
+1 -1
src/screens/Settings/Settings.tsx
··· 45 45 import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' 46 46 import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' 47 47 import {Contacts_Stroke2_Corner2_Rounded as ContactsIcon} from '#/components/icons/Contacts' 48 - import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 48 + import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 49 49 import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' 50 50 import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock' 51 51 import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller'
+1 -1
src/screens/StarterPack/StarterPackScreen.tsx
··· 55 55 import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' 56 56 import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 57 57 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 58 - import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 58 + import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 59 59 import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' 60 60 import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' 61 61 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+1 -1
src/view/com/composer/drafts/DraftItem.tsx
··· 11 11 import {Button} from '#/components/Button' 12 12 import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlusIcon} from '#/components/icons/CirclePlus' 13 13 import {type Props as SVGIconProps} from '#/components/icons/common' 14 - import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid' 14 + import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid' 15 15 import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote' 16 16 import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 17 17 import * as MediaPreview from '#/components/MediaPreview'
+1 -1
src/view/com/profile/ProfileMenu.tsx
··· 32 32 import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' 33 33 import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' 34 34 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 35 - import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 35 + import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 36 36 import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 37 37 import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' 38 38 import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
+1 -1
src/view/shell/desktop/LeftNav.tsx
··· 43 43 BulletList_Filled_Corner0_Rounded as ListFilled, 44 44 BulletList_Stroke2_Corner0_Rounded as List, 45 45 } from '#/components/icons/BulletList' 46 - import {DotGrid_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 46 + import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 47 47 import {EditBig_Stroke2_Corner0_Rounded as EditBig} from '#/components/icons/EditBig' 48 48 import { 49 49 Hashtag_Filled_Corner0_Rounded as HashtagFilled,
+1 -1
src/view/shell/desktop/SidebarTrendingTopics.tsx
··· 11 11 import {useTrendingConfig} from '#/state/service-config' 12 12 import {atoms as a, useTheme} from '#/alf' 13 13 import {Button, ButtonIcon} from '#/components/Button' 14 - import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 14 + import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 15 15 import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' 16 16 import * as Prompt from '#/components/Prompt' 17 17 import {TrendingTopicLink} from '#/components/TrendingTopics'