diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index c5e6200527..8a12d7559f 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -6,7 +6,7 @@ import { AtUri, RichText as RichTextApi, } from '@atproto/api' -import {msg, Plural, Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' @@ -17,8 +17,10 @@ import { useAddSavedFeedsMutation, usePreferencesQuery, useRemoveFeedMutation, + useUpdateSavedFeedsMutation, } from '#/state/queries/preferences' import {useSession} from '#/state/session' +import {formatCount} from '#/view/com/util/numeric/format' import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' @@ -28,6 +30,7 @@ import { type ButtonProps, ButtonText, } from '#/components/Button' +import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2' import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' import {Link as InternalLink, type LinkProps} from '#/components/Link' import {Loader} from '#/components/Loader' @@ -134,9 +137,9 @@ export function TitleAndByline({ {creator && ( - Feed by {sanitizeHandle(creator.handle, '@')} + By {sanitizeHandle(creator.handle, '@')} )} @@ -213,12 +216,14 @@ export function DescriptionPlaceholder() { export function Likes({count}: {count: number}) { const t = useTheme() + const {i18n} = useLingui() return ( - - - Liked by - - + + + + {formatCount(i18n, count)} + + ) } @@ -252,6 +257,8 @@ function SaveButtonInner({ useAddSavedFeedsMutation() const {isPending: isRemovePending, mutateAsync: removeFeed} = useRemoveFeedMutation() + const {isPending: isUpdatePending, mutateAsync: updateSavedFeeds} = + useUpdateSavedFeedsMutation() const uri = view.uri const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list' @@ -259,23 +266,26 @@ function SaveButtonInner({ const savedFeedConfig = React.useMemo(() => { return preferences?.savedFeeds?.find(feed => feed.value === uri) }, [preferences?.savedFeeds, uri]) + const isPinned = savedFeedConfig?.pinned ?? false const removePromptControl = Prompt.usePromptControl() - const isPending = isAddSavedFeedPending || isRemovePending + const isPending = isAddSavedFeedPending || isRemovePending || isUpdatePending - const toggleSave = React.useCallback( + const onPinFeed = React.useCallback( async (e: GestureResponderEvent) => { e.preventDefault() e.stopPropagation() try { if (savedFeedConfig) { - await removeFeed(savedFeedConfig) + // Feed is saved but not pinned, update it to be pinned + await updateSavedFeeds([{...savedFeedConfig, pinned: true}]) } else { + // Feed is not saved, save it with pinned=true await saveFeeds([ { type, value: uri, - pinned: pin || false, + pinned: true, }, ]) } @@ -285,10 +295,22 @@ function SaveButtonInner({ Toast.show(_(msg`Failed to update feeds`), 'xmark') } }, - [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type], + [_, pin, saveFeeds, updateSavedFeeds, uri, savedFeedConfig, type], ) - const onPrompRemoveFeed = React.useCallback( + const onRemoveFeed = React.useCallback(async () => { + try { + if (savedFeedConfig) { + await removeFeed(savedFeedConfig) + } + Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) + } catch (err: any) { + logger.error(err, {message: `FeedCard: failed to remove feed`}) + Toast.show(_(msg`Failed to update feeds`), 'xmark') + } + }, [_, removeFeed, savedFeedConfig]) + + const onPromptRemoveFeed = React.useCallback( async (e: GestureResponderEvent) => { e.preventDefault() e.stopPropagation() @@ -302,13 +324,17 @@ function SaveButtonInner({ <> - - - - {noSavedFeedsOfAnyType && ( - - - setCurrentFeeds( - RECOMMENDED_SAVED_FEEDS.map(f => ({ - ...f, - id: TID.nextStr(), - })), - ) - } - /> - - )} - - - Pinned Feeds - - - {preferences ? ( - !pinnedFeeds.length ? ( - - - You don't have any pinned feeds. - - - ) : ( - pinnedFeeds.map(f => ( - - )) - ) - ) : ( - - - - )} - - {noFollowingFeed && ( - - - setCurrentFeeds(feeds => [ - ...feeds, - {...TIMELINE_SAVED_FEED, id: TID.next().toString()}, - ]) - } - /> - - )} - - - Saved Feeds - - - {preferences ? ( - !unpinnedFeeds.length ? ( - - - You don't have any saved feeds. - - - ) : ( - unpinnedFeeds.map(f => ( - - )) - ) - ) : ( - - - - )} - - - - - Feeds are custom algorithms that users build with a little coding - expertise.{' '} - - See this guide - {' '} - for more information. - - - - - - ) -} - -function ListItem({ - feed, - isPinned, - currentFeeds, - setCurrentFeeds, - scrollGesture, - draggedItemId, - draggedFromIndex, - draggedToIndex, -}: { - feed: AppBskyActorDefs.SavedFeed - isPinned: boolean - currentFeeds: AppBskyActorDefs.SavedFeed[] - setCurrentFeeds: React.Dispatch - scrollGesture?: any - draggedItemId?: any - draggedFromIndex?: any - draggedToIndex?: any -}) { - const {_} = useLingui() - const t = useTheme() - const playHaptic = useHaptics() - const feedUri = feed.value - - const [itemHeight, setItemHeight] = useState(0) - const translateY = useSharedValue(0) - const isDragging = useSharedValue(false) - const lastTentativeIndex = useSharedValue(-1) - - const reorderFeeds = useCallback( - (fromIndex: number, toIndex: number) => { - const nextFeeds = currentFeeds.slice() - const [movedItem] = nextFeeds.splice(fromIndex, 1) - nextFeeds.splice(toIndex, 0, movedItem) - setCurrentFeeds(nextFeeds) - }, - [currentFeeds, setCurrentFeeds], - ) - - const panGesture = useMemo(() => { - if (!isPinned || !scrollGesture || !draggedItemId) return Gesture.Native() - - return Gesture.Pan() - .blocksExternalGesture(scrollGesture) - .activeOffsetY([-5, 5]) - .failOffsetX([-20, 20]) - .onStart(() => { - 'worklet' - isDragging.set(true) - const pinnedFeeds = currentFeeds.filter(f => f.pinned) - const currentIndex = pinnedFeeds.findIndex(f => f.id === feed.id) - draggedItemId.set(feed.id) - draggedFromIndex.set(currentIndex) - draggedToIndex.set(currentIndex) - runOnJS(playHaptic)() - }) - .onUpdate(evt => { - 'worklet' - if (itemHeight === 0) return - - const newTranslateY = evt.translationY - const positionOffset = Math.round(newTranslateY / itemHeight) - const currentIndex = currentFeeds - .filter(f => f.pinned) - .findIndex(f => f.id === feed.id) - const pinnedCount = currentFeeds.filter(f => f.pinned).length - const tentativeIndex = Math.max( - 0, - Math.min(currentIndex + positionOffset, pinnedCount - 1), - ) - - if (tentativeIndex !== lastTentativeIndex.get()) { - runOnJS(playHaptic)() - lastTentativeIndex.set(tentativeIndex) - draggedToIndex.set(tentativeIndex) - } - - translateY.set(newTranslateY) - }) - .onEnd(evt => { - 'worklet' - if (itemHeight === 0) { - translateY.set(withSpring(0)) - isDragging.set(false) - draggedItemId.set(null) - draggedFromIndex.set(-1) - draggedToIndex.set(-1) - return - } - - const positionOffset = Math.round(evt.translationY / itemHeight) - const pinnedFeeds = currentFeeds.filter(f => f.pinned) - const currentIndex = pinnedFeeds.findIndex(f => f.id === feed.id) - const newIndex = Math.max( - 0, - Math.min(currentIndex + positionOffset, pinnedFeeds.length - 1), - ) - - translateY.set( - withSpring(0, { - mass: 1.25, - damping: 300, - stiffness: 800, - }), - ) - isDragging.set(false) - lastTentativeIndex.set(-1) - draggedItemId.set(null) - draggedFromIndex.set(-1) - draggedToIndex.set(-1) - - if (newIndex !== currentIndex && currentIndex !== -1) { - runOnJS(reorderFeeds)(currentIndex, newIndex) - } - }) - }, [ - isPinned, - scrollGesture, - draggedItemId, - draggedFromIndex, - draggedToIndex, - itemHeight, - currentFeeds, - feed.id, - playHaptic, - reorderFeeds, - isDragging, - lastTentativeIndex, - translateY, - ]) - - const animatedStyle = useAnimatedStyle(() => { - if (!draggedItemId || !isPinned) { - return { - transform: [{translateY: 0}], - opacity: 1, - zIndex: 0, - } - } - - const isBeingDragged = draggedItemId.get() === feed.id - - if (isBeingDragged) { - // This is the item being dragged - return { - transform: [ - {translateY: translateY.get()}, - {scale: isDragging.get() ? 1.02 : 1}, - ], - opacity: isDragging.get() ? 0.8 : 1, - zIndex: isDragging.get() ? 10 : 0, - } - } - - // This is a non-dragged item - calculate if it should shift - const pinnedFeeds = currentFeeds.filter(f => f.pinned) - const myIndex = pinnedFeeds.findIndex(f => f.id === feed.id) - const fromIndex = draggedFromIndex.get() - const toIndex = draggedToIndex.get() - - if (myIndex === -1 || fromIndex === -1 || toIndex === -1) { - return { - transform: [{translateY: 0}], - opacity: 1, - zIndex: 0, - } - } - - let offset = 0 - - if (fromIndex < toIndex) { - // Dragging downward - if (myIndex > fromIndex && myIndex <= toIndex) { - offset = -itemHeight // Shift up - } - } else if (fromIndex > toIndex) { - // Dragging upward - if (myIndex < fromIndex && myIndex >= toIndex) { - offset = itemHeight // Shift down - } - } - - return { - transform: [ - { - translateY: withSpring(offset, { - mass: 1.25, - damping: 300, - stiffness: 800, - }), - }, - ], - opacity: 1, - zIndex: 0, - } - }, [draggedItemId, isPinned, feed.id, currentFeeds, itemHeight]) - - const onTogglePinned = async () => { - playHaptic() - setCurrentFeeds( - currentFeeds.map(f => - f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, - ), - ) - } - - const onPressRemove = async () => { - playHaptic() - setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) - } - - return ( - - { - if (itemHeight === 0) { - setItemHeight(e.nativeEvent.layout.height) - } - }}> - {/* Drag Handle - only for pinned feeds */} - {isPinned && ( - - - - - - )} - - {/* Feed Card */} - {feed.type === 'timeline' ? ( - - ) : ( - - )} - - {/* Action Buttons */} - - {!isPinned && ( - - )} - - - - - ) -} - -function SectionHeaderText({children}: {children: React.ReactNode}) { - // eslint-disable-next-line bsky-internal/avoid-unwrapped-text - return ( - - {children} - - ) -} - -function FollowingFeedCard() { - const t = useTheme() - return ( - - - - - - - Following - - - - ) + // Show empty view while redirecting + return } diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 9daacc3a8b..4954f255e7 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -209,13 +209,48 @@ export function useOverwriteSavedFeedsMutation() { const queryClient = useQueryClient() const agent = useAgent() - return useMutation({ + return useMutation< + void, + unknown, + AppBskyActorDefs.SavedFeed[], + {previousPrefs: UsePreferencesQueryResponse | undefined} + >({ mutationFn: async savedFeeds => { await agent.overwriteSavedFeeds(savedFeeds) - // triggers a refetch - await queryClient.invalidateQueries({ - queryKey: preferencesQueryKey, - }) + }, + onMutate: async newSavedFeeds => { + // Cancel any outgoing refetches so they don't overwrite our optimistic update + await queryClient.cancelQueries({queryKey: preferencesQueryKey}) + + // Snapshot the previous value + const previousPrefs = + queryClient.getQueryData( + preferencesQueryKey, + ) + + // Optimistically update the cache + if (previousPrefs) { + queryClient.setQueryData( + preferencesQueryKey, + { + ...previousPrefs, + savedFeeds: newSavedFeeds, + }, + ) + } + + // Return context with the previous value for rollback + return {previousPrefs} + }, + onError: (_err, _newSavedFeeds, context) => { + // Rollback to the previous value on error + if (context?.previousPrefs) { + queryClient.setQueryData(preferencesQueryKey, context.previousPrefs) + } + }, + onSettled: () => { + // Always refetch after error or success to ensure server state consistency + queryClient.invalidateQueries({queryKey: preferencesQueryKey}) }, }) } @@ -227,14 +262,51 @@ export function useAddSavedFeedsMutation() { return useMutation< void, unknown, - Pick[] + Pick[], + {previousPrefs: UsePreferencesQueryResponse | undefined} >({ mutationFn: async savedFeeds => { await agent.addSavedFeeds(savedFeeds) - // triggers a refetch - await queryClient.invalidateQueries({ - queryKey: preferencesQueryKey, - }) + }, + onMutate: async newFeeds => { + // Cancel any outgoing refetches so they don't overwrite our optimistic update + await queryClient.cancelQueries({queryKey: preferencesQueryKey}) + + // Snapshot the previous value + const previousPrefs = + queryClient.getQueryData( + preferencesQueryKey, + ) + + // Optimistically update the cache + if (previousPrefs) { + // Generate temporary IDs for new feeds + const newSavedFeeds = newFeeds.map((feed, index) => ({ + ...feed, + id: `temp-${Date.now()}-${index}`, + })) + + queryClient.setQueryData( + preferencesQueryKey, + { + ...previousPrefs, + savedFeeds: [...previousPrefs.savedFeeds, ...newSavedFeeds], + }, + ) + } + + // Return context with the previous value for rollback + return {previousPrefs} + }, + onError: (_err, _newFeeds, context) => { + // Rollback to the previous value on error + if (context?.previousPrefs) { + queryClient.setQueryData(preferencesQueryKey, context.previousPrefs) + } + }, + onSettled: () => { + // Always refetch after error or success to ensure server state consistency + queryClient.invalidateQueries({queryKey: preferencesQueryKey}) }, }) } diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 3f3c9aeb57..58087e8e24 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,11 +1,23 @@ -import React from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {type AppBskyFeedDefs} from '@atproto/api' +import React, {useCallback, useMemo, useState} from 'react' +import { + ActivityIndicator, + Pressable, + StyleSheet, + View, + type ViewStyle, +} from 'react-native' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' import debounce from 'lodash.debounce' +import {useHaptics} from '#/lib/haptics' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' @@ -13,6 +25,7 @@ import {ComposeIcon2} from '#/lib/icons' import { type CommonNavigatorParams, type NativeStackScreenProps, + type NavigationProp, } from '#/lib/routes/types' import {cleanError} from '#/lib/strings/errors' import {s} from '#/lib/styles' @@ -23,8 +36,10 @@ import { useSavedFeeds, useSearchPopularFeedsMutation, } from '#/state/queries/feed' -import {useSession} from '#/state/session' +import {useUpdateSavedFeedsMutation} from '#/state/queries/preferences' +import {useAgent, useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' +import {useSetSelectedFeed} from '#/state/shell/selected-feed' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {FAB} from '#/view/com/util/fab/FAB' import {List, type ListMethods} from '#/view/com/util/List' @@ -33,18 +48,14 @@ import {Text} from '#/view/com/util/text/Text' import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' import {atoms as a, useTheme} from '#/alf' -import {ButtonIcon} from '#/components/Button' +import {Button, ButtonIcon} from '#/components/Button' import {Divider} from '#/components/Divider' import * as FeedCard from '#/components/FeedCard' import {SearchInput} from '#/components/forms/SearchInput' -import {IconCircle} from '#/components/IconCircle' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' -import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass' -import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' -import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' +import {Menu_Stroke2_Corner0_Rounded as DragHandleIcon} from '#/components/icons/Menu' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import * as Layout from '#/components/Layout' -import {Link} from '#/components/Link' import * as ListCard from '#/components/ListCard' type Props = NativeStackScreenProps @@ -68,34 +79,11 @@ type FlatlistSlice = key: string } | { - type: 'savedFeed' - key: string - savedFeed: SavedFeedItem - } - | { - type: 'savedFeedsLoadMore' + type: 'pinnedFeedsSection' key: string } | { - type: 'popularFeedsHeader' - key: string - } - | { - type: 'popularFeedsLoading' - key: string - } - | { - type: 'popularFeedsNoResults' - key: string - } - | { - type: 'popularFeed' - key: string - feedUri: string - feed: AppBskyFeedDefs.GeneratorView - } - | { - type: 'popularFeedsLoadingMore' + type: 'discoverFeedsSection' key: string } | { @@ -104,17 +92,513 @@ type FlatlistSlice = } export function FeedsScreen(_props: Props) { - const pal = usePalette('default') - const {openComposer} = useOpenComposer() - const {isMobile} = useWebMediaQueries() - const [query, setQuery] = React.useState('') - const [isPTR, setIsPTR] = React.useState(false) const { data: savedFeeds, isPlaceholderData: isSavedFeedsPlaceholder, error: savedFeedsError, refetch: refetchSavedFeeds, } = useSavedFeeds() + + // Calculate stable metadata - only primitives + const pinnedFeeds = savedFeeds?.feeds?.filter(s => s.config.pinned) ?? [] + const savedFeedsCount = savedFeeds?.count ?? 0 + const savedFeedsLength = savedFeeds?.feeds?.length ?? 0 + const pinnedCount = pinnedFeeds.length + const hasFollowingFeed = pinnedFeeds.some(f => f.type === 'timeline') + + return ( + + ) +} + +type FeedsScreenInnerProps = { + savedFeedsCount: number + savedFeedsLength: number + pinnedCount: number + hasFollowingFeed: boolean + isSavedFeedsPlaceholder: boolean + savedFeedsError: Error | null + refetchSavedFeeds: () => Promise +} + +const FeedsScreenInner = React.memo( + function FeedsScreenInner({ + savedFeedsCount, + savedFeedsLength, + pinnedCount, + hasFollowingFeed, + isSavedFeedsPlaceholder, + savedFeedsError, + refetchSavedFeeds, + }: FeedsScreenInnerProps) { + const pal = usePalette('default') + const {openComposer} = useOpenComposer() + const [isPTR, setIsPTR] = React.useState(false) + + // Overlay state - use shared value for visibility to avoid re-renders on drop + // The state is only updated on drag START (to show correct content), not on drop + const [overlayFeed, setOverlayFeed] = useState(null) + const overlayY = useSharedValue(0) + const overlayVisible = useSharedValue(0) // 0 = hidden, 1 = visible + + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {hasSession} = useSession() + const listRef = React.useRef(null) + + // Refs for DiscoverFeedsSection to call for pull-to-refresh and pagination + const discoverRefetchRef = React.useRef<(() => Promise) | null>(null) + const discoverFetchMoreRef = React.useRef<(() => void) | null>(null) + + const onPressCompose = React.useCallback(() => { + openComposer({}) + }, [openComposer]) + + const onPullToRefresh = React.useCallback(async () => { + setIsPTR(true) + await Promise.all([ + refetchSavedFeeds().catch(_e => undefined), + discoverRefetchRef.current?.().catch(_e => undefined), + ]) + setIsPTR(false) + }, [setIsPTR, refetchSavedFeeds]) + + const onEndReached = React.useCallback(() => { + discoverFetchMoreRef.current?.() + }, []) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const items = React.useMemo(() => { + let slices: FlatlistSlice[] = [] + const hasActualSavedCount = + !isSavedFeedsPlaceholder || + (isSavedFeedsPlaceholder && savedFeedsCount > 0) + const canShowDiscoverSection = + !hasSession || (hasSession && hasActualSavedCount) + + if (hasSession) { + slices.push({ + key: 'savedFeedsHeader', + type: 'savedFeedsHeader', + }) + + if (savedFeedsError) { + slices.push({ + key: 'savedFeedsError', + type: 'error', + error: cleanError(savedFeedsError.toString()), + }) + } else { + if (isSavedFeedsPlaceholder && !savedFeedsLength) { + const min = 8 + const count = savedFeedsCount === 0 ? min : savedFeedsCount + Array(count) + .fill(0) + .forEach((_, i) => { + slices.push({ + key: 'savedFeedPlaceholder' + i, + type: 'savedFeedPlaceholder', + }) + }) + } else { + if (savedFeedsLength > 0) { + // Render all pinned feeds as a single section + if (pinnedCount > 0) { + slices.push({ + key: 'pinnedFeedsSection', + type: 'pinnedFeedsSection', + }) + } + + if (!hasFollowingFeed && pinnedCount > 0) { + slices.push({ + key: 'noFollowingFeed', + type: 'noFollowingFeed', + }) + } + } else { + slices.push({ + key: 'savedFeedNoResults', + type: 'savedFeedNoResults', + }) + } + } + } + } + + if (!hasSession || (hasSession && canShowDiscoverSection)) { + slices.push({ + key: 'discoverFeedsSection', + type: 'discoverFeedsSection', + }) + } + + return slices + }, [ + hasSession, + savedFeedsCount, + savedFeedsLength, + pinnedCount, + hasFollowingFeed, + isSavedFeedsPlaceholder, + savedFeedsError, + ]) + + const renderItem = React.useCallback( + ({item}: {item: FlatlistSlice}) => { + if (item.type === 'error') { + return + } else if (item.type === 'savedFeedsHeader') { + return + } else if (item.type === 'savedFeedNoResults') { + return ( + + + + ) + } else if (item.type === 'savedFeedPlaceholder') { + return + } else if (item.type === 'pinnedFeedsSection') { + // Render the entire pinned feeds section as one component + // This component manages its own data subscription, preventing re-renders of FeedsScreen + return ( + + ) + } else if (item.type === 'discoverFeedsSection') { + return ( + + ) + } else if (item.type === 'noFollowingFeed') { + return ( + + + + ) + } + return null + }, + [ + pal.border, + overlayY, + overlayVisible, + setOverlayFeed, + discoverRefetchRef, + listRef, + ], + ) + + return ( + + + + + + + + Feeds + + + + + + item.key} + contentContainerStyle={styles.contentContainer} + renderItem={renderItem} + refreshing={isPTR} + onRefresh={onPullToRefresh} + onEndReached={onEndReached} + onEndReachedThreshold={2} + initialNumToRender={10} + desktopFixedHeight + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + sideBorders={false} + /> + + + {hasSession && ( + + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + )} + + + {/* Drag overlay - rendered outside Layout.Screen for correct absolute positioning */} + {overlayFeed && ( + + )} + + ) + }, + (prev, next) => { + // Only re-render if structural values change + const isEqual = + prev.savedFeedsCount === next.savedFeedsCount && + prev.savedFeedsLength === next.savedFeedsLength && + prev.pinnedCount === next.pinnedCount && + prev.hasFollowingFeed === next.hasFollowingFeed && + prev.isSavedFeedsPlaceholder === next.isSavedFeedsPlaceholder && + // Compare error by message, not reference + (prev.savedFeedsError?.message ?? null) === + (next.savedFeedsError?.message ?? null) + return isEqual + }, +) + +// Separate component for pinned feeds - manages its own drag state AND data subscription +// This prevents drag operations and query updates from re-rendering FeedsScreen +const PinnedFeedsSection = React.memo(function PinnedFeedsSection({ + overlayY, + overlayVisible, + onOverlayFeedChange, +}: { + overlayY: Animated.SharedValue + overlayVisible: Animated.SharedValue + onOverlayFeedChange: (feed: SavedFeedItem | null) => void +}) { + const {mutateAsync: updateSavedFeeds} = useUpdateSavedFeedsMutation() + const agent = useAgent() + + // Subscribe to saved feeds directly + const {data: savedFeeds} = useSavedFeeds() + const serverFeeds = React.useMemo( + () => savedFeeds?.feeds?.filter(f => f.config.pinned) ?? [], + [savedFeeds?.feeds], + ) + + // Use a ref for feed order to avoid re-render issues during drag + // The ref holds the current order, state is just used to trigger re-renders + const feedsRef = React.useRef(serverFeeds) + const [, forceRender] = useState(0) + + // Track if we're actively dragging (not just "edited recently") + const isDraggingRef = React.useRef(false) + + // Keep ref in sync with server feeds + // Always sync unless actively dragging, to handle pin/unpin updates + React.useEffect(() => { + if (!isDraggingRef.current) { + // Check if feeds actually changed (different items or order from server) + const currentIds = feedsRef.current.map(f => f.config.id).join(',') + const serverIds = serverFeeds.map(f => f.config.id).join(',') + if (currentIds !== serverIds) { + feedsRef.current = serverFeeds + forceRender(n => n + 1) + } + } + }, [serverFeeds]) + + // Always read from ref + const feeds = feedsRef.current + + // Debounced save to server - completely silent, no React Query involvement + const savedFeedsRef = React.useRef(savedFeeds) + savedFeedsRef.current = savedFeeds + const debouncedSave = React.useMemo( + () => + debounce((newLocalFeeds: SavedFeedItem[]) => { + const allFeeds = savedFeedsRef.current?.feeds + if (!allFeeds) return + + const unpinnedFeeds = allFeeds.filter(f => !f.config.pinned) + const newOrder = [...newLocalFeeds, ...unpinnedFeeds].map(f => f.config) + + agent.overwriteSavedFeeds(newOrder).catch(() => { + // Silently ignore errors for now + }) + }, 500), + [agent], + ) + + // Drag state + const scrollGesture = useMemo(() => Gesture.Native(), []) + const itemHeightRef = React.useRef(0) + const itemHeightShared = useSharedValue(0) + const [draggedItem, setDraggedItem] = useState<{ + feed: SavedFeedItem + fromIndex: number + startY: number + } | null>(null) + // Separate hidden state that persists through reorder to prevent flash + const [hiddenFeedId, setHiddenFeedId] = useState(null) + const dragTargetIndexRef = React.useRef(-1) + const [, forceUpdateForShift] = useState(0) + + const onReorder = useCallback( + (fromIndex: number, toIndex: number) => { + // Update the ref directly - no state update, no re-render yet + const newFeeds = [...feedsRef.current] + const [moved] = newFeeds.splice(fromIndex, 1) + newFeeds.splice(toIndex, 0, moved) + feedsRef.current = newFeeds + + // Save to server in background (debounced, fire-and-forget) + debouncedSave(newFeeds) + + // Clear ALL visual state together and trigger ONE re-render + setTimeout(() => { + isDraggingRef.current = false + dragTargetIndexRef.current = -1 + setDraggedItem(null) + setHiddenFeedId(null) + overlayVisible.set(0) + forceRender(n => n + 1) + }, 0) + }, + [overlayVisible, debouncedSave], + ) + + return ( + + {feeds.map((feed, index) => { + const dragFromIndex = draggedItem?.fromIndex ?? -1 + const dragTargetIndex = dragTargetIndexRef.current + const height = itemHeightRef.current || 60 + let shiftOffset = 0 + + if ( + dragFromIndex !== -1 && + dragTargetIndex !== -1 && + dragFromIndex !== index + ) { + if (dragFromIndex < dragTargetIndex) { + if (index > dragFromIndex && index <= dragTargetIndex) { + shiftOffset = -height + } + } else if (dragFromIndex > dragTargetIndex) { + if (index < dragFromIndex && index >= dragTargetIndex) { + shiftOffset = height + } + } + } + + return ( + { + isDraggingRef.current = true + dragTargetIndexRef.current = index + setDraggedItem({ + feed, + fromIndex: index, + startY, + }) + setHiddenFeedId(feed.config.id) + onOverlayFeedChange(feed) + overlayVisible.set(1) + }} + onDragUpdate={(currentY: number, targetIndex: number) => { + overlayY.set(currentY) + if (dragTargetIndexRef.current !== targetIndex) { + dragTargetIndexRef.current = targetIndex + forceUpdateForShift(n => n + 1) + } + }} + onDragEnd={(didReorder: boolean) => { + if (!didReorder) { + isDraggingRef.current = false + dragTargetIndexRef.current = -1 + setDraggedItem(null) + setHiddenFeedId(null) + overlayVisible.set(0) + } + }} + onTogglePinned={() => { + // Optimistically remove from local state immediately + feedsRef.current = feedsRef.current.filter( + f => f.config.id !== feed.config.id, + ) + forceRender(n => n + 1) + + // Then update server in background + updateSavedFeeds([ + { + ...feed.config, + pinned: false, + }, + ]) + }} + onReorder={onReorder} + /> + ) + })} + + ) +}) + +// Separate component for discover/popular feeds - manages its own query subscription +// This prevents query updates from re-rendering FeedsScreen +function DiscoverFeedsSection({ + refetchRef, + fetchMoreRef, + listRef, +}: { + refetchRef: React.MutableRefObject<(() => Promise) | null> + fetchMoreRef: React.MutableRefObject<(() => void) | null> + listRef: React.RefObject +}) { + const {_} = useLingui() + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + const [query, setQuery] = React.useState('') + const { data: popularFeeds, isFetching: isPopularFeedsFetching, @@ -124,8 +608,7 @@ export function FeedsScreen(_props: Props) { isFetchingNextPage: isPopularFeedsFetchingNextPage, hasNextPage: hasNextPopularFeedsPage, } = useGetPopularFeedsQuery() - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() + const { data: searchResults, mutate: search, @@ -133,20 +616,39 @@ export function FeedsScreen(_props: Props) { isPending: isSearchPending, error: searchError, } = useSearchPopularFeedsMutation() - const {hasSession} = useSession() - const listRef = React.useRef(null) - /** - * A search query is present. We may not have search results yet. - */ const isUserSearching = query.length > 1 const debouncedSearch = React.useMemo( - () => debounce(q => search(q), 500), // debounce for 500ms + () => debounce(q => search(q), 500), [search], ) - const onPressCompose = React.useCallback(() => { - openComposer({}) - }, [openComposer]) + + // Expose refetch for pull-to-refresh + React.useEffect(() => { + refetchRef.current = async () => { + await refetchPopularFeeds() + } + }, [refetchRef, refetchPopularFeeds]) + + // Expose fetchNextPage for infinite scroll + React.useEffect(() => { + fetchMoreRef.current = () => { + if ( + hasNextPopularFeedsPage && + !isPopularFeedsFetchingNextPage && + !isUserSearching + ) { + fetchNextPopularFeedsPage() + } + } + }, [ + fetchMoreRef, + hasNextPopularFeedsPage, + isPopularFeedsFetchingNextPage, + isUserSearching, + fetchNextPopularFeedsPage, + ]) + const onChangeQuery = React.useCallback( (text: string) => { setQuery(text) @@ -159,500 +661,541 @@ export function FeedsScreen(_props: Props) { }, [setQuery, refetchPopularFeeds, debouncedSearch, resetSearch], ) + const onPressCancelSearch = React.useCallback(() => { setQuery('') refetchPopularFeeds() resetSearch() }, [refetchPopularFeeds, setQuery, resetSearch]) + const onSubmitQuery = React.useCallback(() => { debouncedSearch(query) }, [query, debouncedSearch]) - const onPullToRefresh = React.useCallback(async () => { - setIsPTR(true) - await Promise.all([ - refetchSavedFeeds().catch(_e => undefined), - refetchPopularFeeds().catch(_e => undefined), - ]) - setIsPTR(false) - }, [setIsPTR, refetchSavedFeeds, refetchPopularFeeds]) - const onEndReached = React.useCallback(() => { - if ( - isPopularFeedsFetching || - isUserSearching || - !hasNextPopularFeedsPage || - popularFeedsError - ) - return - fetchNextPopularFeedsPage() - }, [ - isPopularFeedsFetching, - isUserSearching, - popularFeedsError, - hasNextPopularFeedsPage, - fetchNextPopularFeedsPage, - ]) - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - const items = React.useMemo(() => { - let slices: FlatlistSlice[] = [] - const hasActualSavedCount = - !isSavedFeedsPlaceholder || - (isSavedFeedsPlaceholder && (savedFeeds?.count || 0) > 0) - const canShowDiscoverSection = - !hasSession || (hasSession && hasActualSavedCount) - - if (hasSession) { - slices.push({ - key: 'savedFeedsHeader', - type: 'savedFeedsHeader', - }) - - if (savedFeedsError) { - slices.push({ - key: 'savedFeedsError', - type: 'error', - error: cleanError(savedFeedsError.toString()), - }) - } else { - if (isSavedFeedsPlaceholder && !savedFeeds?.feeds.length) { - /* - * Initial render in placeholder state is 0 on a cold page load, - * because preferences haven't loaded yet. - * - * In practice, `savedFeeds` is always defined, but we check for TS - * and for safety. - * - * In both cases, we show 4 as the the loading state. - */ - const min = 8 - const count = savedFeeds - ? savedFeeds.count === 0 - ? min - : savedFeeds.count - : min - Array(count) - .fill(0) - .forEach((_, i) => { - slices.push({ - key: 'savedFeedPlaceholder' + i, - type: 'savedFeedPlaceholder', - }) - }) - } else { - if (savedFeeds?.feeds?.length) { - const noFollowingFeed = savedFeeds.feeds.every( - f => f.type !== 'timeline', - ) - - slices = slices.concat( - savedFeeds.feeds - .filter(s => { - return s.config.pinned - }) - .map(s => ({ - key: `savedFeed:${s.view?.uri}:${s.config.id}`, - type: 'savedFeed', - savedFeed: s, - })), - ) - slices = slices.concat( - savedFeeds.feeds - .filter(s => { - return !s.config.pinned - }) - .map(s => ({ - key: `savedFeed:${s.view?.uri}:${s.config.id}`, - type: 'savedFeed', - savedFeed: s, - })), - ) - - if (noFollowingFeed) { - slices.push({ - key: 'noFollowingFeed', - type: 'noFollowingFeed', - }) - } - } else { - slices.push({ - key: 'savedFeedNoResults', - type: 'savedFeedNoResults', - }) - } - } - } - } - - if (!hasSession || (hasSession && canShowDiscoverSection)) { - slices.push({ - key: 'popularFeedsHeader', - type: 'popularFeedsHeader', - }) - - if (popularFeedsError || searchError) { - slices.push({ - key: 'popularFeedsError', - type: 'error', - error: cleanError( - popularFeedsError?.toString() ?? searchError?.toString() ?? '', - ), - }) - } else { - if (isUserSearching) { - if (isSearchPending || !searchResults) { - slices.push({ - key: 'popularFeedsLoading', - type: 'popularFeedsLoading', - }) - } else { - if (!searchResults || searchResults?.length === 0) { - slices.push({ - key: 'popularFeedsNoResults', - type: 'popularFeedsNoResults', - }) - } else { - slices = slices.concat( - searchResults.map(feed => ({ - key: `popularFeed:${feed.uri}`, - type: 'popularFeed', - feedUri: feed.uri, - feed, - })), - ) - } - } - } else { - if (isPopularFeedsFetching && !popularFeeds?.pages) { - slices.push({ - key: 'popularFeedsLoading', - type: 'popularFeedsLoading', - }) - } else { - if (!popularFeeds?.pages) { - slices.push({ - key: 'popularFeedsNoResults', - type: 'popularFeedsNoResults', - }) - } else { - for (const page of popularFeeds.pages || []) { - slices = slices.concat( - page.feeds.map(feed => ({ - key: `popularFeed:${feed.uri}`, - type: 'popularFeed', - feedUri: feed.uri, - feed, - })), - ) - } - - if (isPopularFeedsFetchingNextPage) { - slices.push({ - key: 'popularFeedsLoadingMore', - type: 'popularFeedsLoadingMore', - }) - } - } - } - } - } - } - - return slices - }, [ - hasSession, - savedFeeds, - isSavedFeedsPlaceholder, - savedFeedsError, - popularFeeds, - isPopularFeedsFetching, - popularFeedsError, - isPopularFeedsFetchingNextPage, - searchResults, - isSearchPending, - searchError, - isUserSearching, - ]) - - const searchBarIndex = items.findIndex( - item => item.type === 'popularFeedsHeader', - ) + const searchBarIndex = 0 // First item in this section const onChangeSearchFocus = React.useCallback( (focus: boolean) => { - if (focus && searchBarIndex > -1) { + if (focus && listRef.current) { + // Scroll to show discover section if (isNative) { - // scrollToIndex scrolls the exact right amount, so use if available - listRef.current?.scrollToIndex({ - index: searchBarIndex, + listRef.current.scrollToIndex({ + index: searchBarIndex + 2, // Account for header items animated: true, }) } else { - // web implementation only supports scrollToOffset - // thus, we calculate the offset based on the index - // pixel values are estimates, I wasn't able to get it pixel perfect :( const headerHeight = isMobile ? 43 : 53 const feedItemHeight = isMobile ? 49 : 58 - listRef.current?.scrollToOffset({ - offset: searchBarIndex * feedItemHeight - headerHeight, + listRef.current.scrollToOffset({ + offset: (searchBarIndex + 2) * feedItemHeight - headerHeight, animated: true, }) } } }, - [searchBarIndex, isMobile], + [listRef, isMobile], ) - const renderItem = React.useCallback( - ({item}: {item: FlatlistSlice}) => { - if (item.type === 'error') { - return - } else if (item.type === 'popularFeedsLoadingMore') { - return ( - - - - ) - } else if (item.type === 'savedFeedsHeader') { - return - } else if (item.type === 'savedFeedNoResults') { - return ( - - - - ) - } else if (item.type === 'savedFeedPlaceholder') { - return - } else if (item.type === 'savedFeed') { - return - } else if (item.type === 'popularFeedsHeader') { - return ( - <> - - - onChangeSearchFocus(true)} - onBlur={() => onChangeSearchFocus(false)} - /> - - - ) - } else if (item.type === 'popularFeedsLoading') { - return - } else if (item.type === 'popularFeed') { - return ( - - - - - ) - } else if (item.type === 'popularFeedsNoResults') { - return ( - - - No results found for "{query}" - - - ) - } else if (item.type === 'noFollowingFeed') { - return ( - - - - ) - } - return null - }, - [ - _, - pal.border, - pal.textLight, - query, - onChangeQuery, - onPressCancelSearch, - onSubmitQuery, - onChangeSearchFocus, - ], - ) + // Render feeds list + const feeds = React.useMemo(() => { + if (isUserSearching) { + return searchResults ?? [] + } + if (!popularFeeds?.pages) return [] + return popularFeeds.pages.flatMap(page => page.feeds) + }, [isUserSearching, searchResults, popularFeeds?.pages]) + + const isLoading = isUserSearching + ? isSearchPending || !searchResults + : isPopularFeedsFetching && !popularFeeds?.pages + + const hasError = popularFeedsError || searchError + const hasNoResults = isUserSearching + ? searchResults?.length === 0 + : !popularFeeds?.pages return ( - - - - - - - Feeds - - - - - - - - - - item.key} - contentContainerStyle={styles.contentContainer} - renderItem={renderItem} - refreshing={isPTR} - onRefresh={isUserSearching ? undefined : onPullToRefresh} - initialNumToRender={10} - onEndReached={onEndReached} - desktopFixedHeight - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - sideBorders={false} + + + + onChangeSearchFocus(true)} + onBlur={() => onChangeSearchFocus(false)} /> - + - {hasSession && ( - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" + {hasError ? ( + - )} - - ) -} - -function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) { - return savedFeed.type === 'timeline' ? ( - - ) : ( - - ) -} - -function FollowingFeed() { - const t = useTheme() - const {_} = useLingui() - return ( - - - - + ) : isLoading ? ( + + ) : hasNoResults && !isUserSearching ? ( + + + No feeds found + - - + ) : hasNoResults && isUserSearching ? ( + + + No results found for "{query}" + + + ) : ( + <> + {feeds.map(feed => ( + + + + + ))} + {isPopularFeedsFetchingNextPage && ( + + + + )} + {!isUserSearching && + hasNextPopularFeedsPage && + !isPopularFeedsFetchingNextPage && ( + fetchNextPopularFeedsPage()} + style={[a.p_lg, a.align_center]}> + + Load more + + + )} + + )} ) } -function SavedFeed({ +type EditableFeedItemProps = { + savedFeed: SavedFeedItem + isPinned: boolean + pinnedIndex?: number + pinnedCount?: number + isBeingDragged: boolean + shiftOffset: number + scrollGesture: ReturnType + itemHeightRef: React.RefObject + itemHeightShared: Animated.SharedValue + draggedCurrentY: Animated.SharedValue + onDragStart: (startY: number) => void + onDragUpdate: (currentY: number, targetIndex: number) => void + onDragEnd: (didReorder: boolean) => void + onTogglePinned: () => Promise + onReorder: (fromIndex: number, toIndex: number) => void +} + +function areEditableFeedItemPropsEqual( + prev: EditableFeedItemProps, + next: EditableFeedItemProps, +): boolean { + return ( + prev.savedFeed.config.id === next.savedFeed.config.id && + prev.isPinned === next.isPinned && + prev.pinnedIndex === next.pinnedIndex && + prev.pinnedCount === next.pinnedCount && + prev.isBeingDragged === next.isBeingDragged && + prev.shiftOffset === next.shiftOffset + ) +} + +const EditableFeedItem = React.memo(function EditableFeedItem({ savedFeed, + isPinned, + pinnedIndex, + pinnedCount, + isBeingDragged, + shiftOffset, + scrollGesture, + itemHeightRef, + itemHeightShared, + draggedCurrentY, + onDragStart, + onDragUpdate, + onDragEnd, + onTogglePinned, + onReorder, +}: EditableFeedItemProps) { + const t = useTheme() + const {_} = useLingui() + const playHaptic = useHaptics() + + const onDragStartRef = React.useRef(onDragStart) + const onDragUpdateRef = React.useRef(onDragUpdate) + const onDragEndRef = React.useRef(onDragEnd) + const onReorderRef = React.useRef(onReorder) + React.useEffect(() => { + onDragStartRef.current = onDragStart + onDragUpdateRef.current = onDragUpdate + onDragEndRef.current = onDragEnd + onReorderRef.current = onReorder + }) + + const [hovered, setHovered] = useState(false) + const startY = useSharedValue(0) + const startX = useSharedValue(0) + const lastTentativeIndex = useSharedValue(-1) + const isDragging = useSharedValue(false) + const isActivated = useSharedValue(false) + + const handleDragStart = React.useCallback((y: number) => { + onDragStartRef.current(y) + }, []) + const handleDragUpdate = React.useCallback((y: number, idx: number) => { + onDragUpdateRef.current(y, idx) + }, []) + const handleDragEnd = React.useCallback((didReorder: boolean) => { + onDragEndRef.current(didReorder) + }, []) + const handleReorder = React.useCallback((from: number, to: number) => { + onReorderRef.current(from, to) + }, []) + + const panGesture = useMemo(() => { + if (!isPinned || pinnedIndex === undefined || pinnedCount === undefined) { + return Gesture.Native() + } + + return Gesture.Pan() + .blocksExternalGesture(scrollGesture) + .manualActivation(true) + .onTouchesDown((evt, stateManager) => { + 'worklet' + // Immediately show visual feedback on touch + const touch = evt.allTouches[0] + if (!touch) return + startY.set(touch.absoluteY) + startX.set(touch.absoluteX) + draggedCurrentY.set(touch.absoluteY) + lastTentativeIndex.set(pinnedIndex) + isDragging.set(true) + isActivated.set(false) + runOnJS(playHaptic)() + runOnJS(handleDragStart)(touch.absoluteY) + // Begin tracking but don't activate yet + stateManager.begin() + }) + .onTouchesMove((evt, stateManager) => { + 'worklet' + const touch = evt.allTouches[0] + if (!touch) return + + const deltaY = Math.abs(touch.absoluteY - startY.get()) + const deltaX = Math.abs(touch.absoluteX - startX.get()) + + // Fail if horizontal movement is too large + if (deltaX > 20) { + isDragging.set(false) + runOnJS(handleDragEnd)(false) + stateManager.fail() + return + } + + // Activate once we've moved 3px vertically + if (deltaY >= 3) { + isActivated.set(true) + stateManager.activate() + } + }) + .onTouchesUp((_evt, stateManager) => { + 'worklet' + // Only handle if gesture never activated (quick tap) + // If activated, onEnd will handle it + if (isDragging.get() && !isActivated.get()) { + isDragging.set(false) + runOnJS(handleDragEnd)(false) + } + stateManager.end() + }) + .onUpdate(evt => { + 'worklet' + const height = itemHeightShared.get() + if (height === 0) return + + const maxUpward = pinnedIndex * height + const maxDownward = + (pinnedCount - 1 - pinnedIndex) * height + height * 0.5 + const deltaY = evt.absoluteY - startY.get() + const clampedDeltaY = Math.max( + -maxUpward, + Math.min(maxDownward, deltaY), + ) + const clampedY = startY.get() + clampedDeltaY + + draggedCurrentY.set(clampedY) + + const upwardBias = clampedDeltaY < 0 ? -height * 0.45 : 0 + const positionOffset = Math.round((clampedDeltaY + upwardBias) / height) + const tentativeIndex = Math.max( + 0, + Math.min(pinnedIndex + positionOffset, pinnedCount - 1), + ) + + if (tentativeIndex !== lastTentativeIndex.get()) { + runOnJS(playHaptic)() + lastTentativeIndex.set(tentativeIndex) + } + + runOnJS(handleDragUpdate)(clampedY, tentativeIndex) + }) + .onEnd(evt => { + 'worklet' + const height = itemHeightShared.get() + + if (height === 0) { + isDragging.set(false) + runOnJS(handleDragEnd)(false) + return + } + + const maxUpward = pinnedIndex * height + const maxDownward = + (pinnedCount - 1 - pinnedIndex) * height + height * 0.5 + const deltaY = evt.absoluteY - startY.get() + const clampedDeltaY = Math.max( + -maxUpward, + Math.min(maxDownward, deltaY), + ) + + const upwardBias = clampedDeltaY < 0 ? -height * 0.45 : 0 + const positionOffset = Math.round((clampedDeltaY + upwardBias) / height) + const newIndex = Math.max( + 0, + Math.min(pinnedIndex + positionOffset, pinnedCount - 1), + ) + + const didReorder = newIndex !== pinnedIndex + isDragging.set(false) + runOnJS(handleDragEnd)(didReorder) + + if (didReorder) { + runOnJS(handleReorder)(pinnedIndex, newIndex) + } + }) + .onFinalize(() => { + 'worklet' + // Final cleanup - only if gesture was never activated and is still dragging + if (isDragging.get() && !isActivated.get()) { + isDragging.set(false) + runOnJS(handleDragEnd)(false) + } + }) + }, [ + isPinned, + pinnedIndex, + pinnedCount, + scrollGesture, + itemHeightShared, + draggedCurrentY, + isDragging, + isActivated, + playHaptic, + handleDragStart, + handleDragUpdate, + handleDragEnd, + handleReorder, + startY, + startX, + lastTentativeIndex, + ]) + + const handleTogglePinned = async () => { + playHaptic() + await onTogglePinned() + } + + const webHoverProps = isWeb + ? { + onMouseEnter: () => setHovered(true), + onMouseLeave: () => setHovered(false), + } + : {} + + return ( + { + const height = e.nativeEvent.layout.height + if (itemHeightRef.current === 0) { + itemHeightRef.current = height + itemHeightShared.set(height) + } + }}> + + {isPinned && } + + + + + + + + + + + ) +}, areEditableFeedItemPropsEqual) + +function FeedItemContent({savedFeed}: {savedFeed: SavedFeedItem}) { + const t = useTheme() + const {_} = useLingui() + const navigation = useNavigation() + const setSelectedFeed = useSetSelectedFeed() + + if (savedFeed.type === 'timeline') { + return ( + { + setSelectedFeed('following') + navigation.navigate('Home') + }} + style={[a.flex_1, a.align_start]}> + + + + + + + + ) + } + + if (savedFeed.type === 'feed') { + return ( + + + + + + + ) + } + + // list type + return ( + + + + + + + ) +} + +function DragOverlay({ + savedFeed, + currentY, + visible, }: { - savedFeed: SavedFeedItem & {type: 'feed' | 'list'} + savedFeed: SavedFeedItem + currentY: Animated.SharedValue + visible: Animated.SharedValue }) { const t = useTheme() - const commonStyle = [ - a.w_full, - a.flex_1, - a.px_lg, - a.py_md, - a.border_b, - t.atoms.border_contrast_low, - ] + // Position overlay at finger position, offset by half item height (~30px) to center it + const animatedStyle = useAnimatedStyle(() => { + const y = currentY.get() + const isVisible = visible.get() + return { + top: y - 30, + opacity: isVisible, + } + }) - return savedFeed.type === 'feed' ? ( - - {({hovered, pressed}) => ( - - - - - - - - - )} - - ) : ( - - {({hovered, pressed}) => ( - - - - - - - - - )} - + return ( + + + + + ) } @@ -675,38 +1218,55 @@ function SavedFeedPlaceholder() { ) } +function DragHandle({ + gesture, +}: { + gesture: ReturnType | ReturnType +}) { + const t = useTheme() + const [hovered, setHovered] = useState(false) + + return ( + + + setHovered(true)} + onHoverOut={() => setHovered(false)} + style={[ + a.justify_center, + a.pl_lg, + a.pr_sm, + a.py_xs, + isWeb && ({cursor: 'grab'} as unknown as ViewStyle), + ]}> + + + + + ) +} + function FeedsSavedHeader() { const t = useTheme() return ( - - - - - My Feeds - - - All the feeds you've saved, right in one place. - - + + + Pinned Feeds + + + Pinned feeds are shown as tabs on Home for easy access. + ) } @@ -715,27 +1275,16 @@ function FeedsAboutHeader() { const t = useTheme() return ( - - - - - Discover New Feeds - - - - Choose your own timeline! Feeds built by the community help you find - content you love. - - - + + + Discover New Feeds + + + + Choose your own timeline! Feeds built by the community help you find + content you love. + + ) } @@ -744,36 +1293,4 @@ const styles = StyleSheet.create({ contentContainer: { paddingBottom: 100, }, - - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: 16, - paddingHorizontal: 18, - paddingVertical: 12, - }, - - savedFeed: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 14, - gap: 12, - borderBottomWidth: StyleSheet.hairlineWidth, - }, - savedFeedMobile: { - paddingVertical: 10, - }, - offlineSlug: { - borderWidth: StyleSheet.hairlineWidth, - borderRadius: 4, - paddingHorizontal: 4, - paddingVertical: 2, - }, - headerBtnGroup: { - flexDirection: 'row', - gap: 15, - alignItems: 'center', - }, }) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index e058e28831..703f0f2cc6 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,6 +1,7 @@ import React from 'react' import {ActivityIndicator, StyleSheet} from 'react-native' import {useFocusEffect} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' import {PROD_DEFAULT_FEED} from '#/lib/constants' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -19,7 +20,10 @@ import { usePinnedFeedsInfos, } from '#/state/queries/feed' import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed' -import {usePreferencesQuery} from '#/state/queries/preferences' +import { + preferencesQueryKey, + usePreferencesQuery, +} from '#/state/queries/preferences' import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' @@ -41,12 +45,20 @@ import {useDemoMode} from '#/storage/hooks/demo-mode' type Props = NativeStackScreenProps export function HomeScreen(props: Props) { + const queryClient = useQueryClient() const {setShowLoggedOut} = useLoggedOutViewControls() const {data: preferences} = usePreferencesQuery() const {currentAccount} = useSession() const {data: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} = usePinnedFeedsInfos() + // Refetch preferences when Home gains focus to sync feed changes + useFocusEffect( + React.useCallback(() => { + queryClient.invalidateQueries({queryKey: preferencesQueryKey}) + }, [queryClient]), + ) + React.useEffect(() => { if (isWeb && !currentAccount) { const getParams = new URLSearchParams(window.location.search)