diff options
Diffstat (limited to 'src/view/com/feeds')
-rw-r--r-- | src/view/com/feeds/FeedPage.tsx | 194 | ||||
-rw-r--r-- | src/view/com/feeds/FeedSourceCard.tsx | 144 | ||||
-rw-r--r-- | src/view/com/feeds/ProfileFeedgens.tsx | 222 |
3 files changed, 439 insertions, 121 deletions
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 1037007b7..f3f07a8bd 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -4,74 +4,56 @@ import { } from '@fortawesome/react-native-fontawesome' import {useIsFocused} from '@react-navigation/native' import {useAnalytics} from '@segment/analytics-react-native' +import {useQueryClient} from '@tanstack/react-query' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {ComposeIcon2} from 'lib/icons' import {colors, s} from 'lib/styles' -import {observer} from 'mobx-react-lite' import React from 'react' -import {FlatList, View} from 'react-native' -import {useStores} from 'state/index' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home' +import {FlatList, View, useWindowDimensions} from 'react-native' import {Feed} from '../posts/Feed' import {TextLink} from '../util/Link' import {FAB} from '../util/fab/FAB' import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn' -import useAppState from 'react-native-appstate-hook' -import {logger} from '#/logger' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession} from '#/state/session' +import {useComposerControls} from '#/state/shell/composer' +import {listenSoftReset, emitSoftReset} from '#/state/events' +import {truncateAndInvalidate} from '#/state/queries/util' -export const FeedPage = observer(function FeedPageImpl({ +const POLL_FREQ = 30e3 // 30sec + +export function FeedPage({ testID, isPageFocused, feed, + feedParams, renderEmptyState, renderEndOfFeed, }: { testID?: string - feed: PostsFeedModel + feed: FeedDescriptor + feedParams?: FeedParams isPageFocused: boolean renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element }) { - const store = useStores() + const {isSandbox, hasSession} = useSession() const pal = usePalette('default') + const {_} = useLingui() const {isDesktop} = useWebMediaQueries() + const queryClient = useQueryClient() + const {openComposer} = useComposerControls() const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() const {screen, track} = useAnalytics() const headerOffset = useHeaderOffset() const scrollElRef = React.useRef<FlatList>(null) - const {appState} = useAppState({ - onForeground: () => doPoll(true), - }) const isScreenFocused = useIsFocused() - const hasNew = feed.hasNewLatest && !feed.isRefreshing - - React.useEffect(() => { - // called on first load - if (!feed.hasLoaded && isPageFocused) { - feed.setup() - } - }, [isPageFocused, feed]) - - const doPoll = React.useCallback( - (knownActive = false) => { - if ( - (!knownActive && appState !== 'active') || - !isScreenFocused || - !isPageFocused - ) { - return - } - if (feed.isLoading) { - return - } - logger.debug('HomeScreen: Polling for new posts') - feed.checkForLatest() - }, - [appState, isScreenFocused, isPageFocused, feed], - ) + const [hasNew, setHasNew] = React.useState(false) const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerOffset}) @@ -81,41 +63,30 @@ export const FeedPage = observer(function FeedPageImpl({ const onSoftReset = React.useCallback(() => { if (isPageFocused) { scrollToTop() - feed.refresh() + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) } - }, [isPageFocused, scrollToTop, feed]) + }, [isPageFocused, scrollToTop, queryClient, feed, setHasNew]) // fires when page within screen is activated/deactivated - // - check for latest React.useEffect(() => { if (!isPageFocused || !isScreenFocused) { return } - - const softResetSub = store.onScreenSoftReset(onSoftReset) - const feedCleanup = feed.registerListeners() - const pollInterval = setInterval(doPoll, POLL_FREQ) - screen('Feed') - logger.debug('HomeScreen: Updating feed') - feed.checkForLatest() - - return () => { - clearInterval(pollInterval) - softResetSub.remove() - feedCleanup() - } - }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) + return listenSoftReset(onSoftReset) + }, [onSoftReset, screen, isPageFocused, isScreenFocused]) const onPressCompose = React.useCallback(() => { track('HomeScreen:PressCompose') - store.shell.openComposer({}) - }, [store, track]) + openComposer({}) + }, [openComposer, track]) const onPressLoadLatest = React.useCallback(() => { scrollToTop() - feed.refresh() - }, [feed, scrollToTop]) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollToTop, feed, queryClient, setHasNew]) const ListHeaderComponent = React.useCallback(() => { if (isDesktop) { @@ -137,7 +108,7 @@ export const FeedPage = observer(function FeedPageImpl({ style={[pal.text, {fontWeight: 'bold'}]} text={ <> - {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} + {isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} {hasNew && ( <View style={{ @@ -151,35 +122,50 @@ export const FeedPage = observer(function FeedPageImpl({ )} </> } - onPress={() => store.emitScreenSoftReset()} - /> - <TextLink - type="title-lg" - href="/settings/home-feed" - style={{fontWeight: 'bold'}} - accessibilityLabel="Feed Preferences" - accessibilityHint="" - text={ - <FontAwesomeIcon - icon="sliders" - style={pal.textLight as FontAwesomeIconStyle} - /> - } + onPress={emitSoftReset} /> + {hasSession && ( + <TextLink + type="title-lg" + href="/settings/home-feed" + style={{fontWeight: 'bold'}} + accessibilityLabel={_(msg`Feed Preferences`)} + accessibilityHint="" + text={ + <FontAwesomeIcon + icon="sliders" + style={pal.textLight as FontAwesomeIconStyle} + /> + } + /> + )} </View> ) } return <></> - }, [isDesktop, pal, store, hasNew]) + }, [ + isDesktop, + pal.view, + pal.text, + pal.textLight, + hasNew, + _, + isSandbox, + hasSession, + ]) return ( <View testID={testID} style={s.h100pct}> <Feed testID={testID ? `${testID}-feed` : undefined} + enabled={isPageFocused} feed={feed} + feedParams={feedParams} + pollInterval={POLL_FREQ} scrollElRef={scrollElRef} onScroll={onMainScroll} - scrollEventThrottle={100} + onHasNew={setHasNew} + scrollEventThrottle={1} renderEmptyState={renderEmptyState} renderEndOfFeed={renderEndOfFeed} ListHeaderComponent={ListHeaderComponent} @@ -188,18 +174,52 @@ export const FeedPage = observer(function FeedPageImpl({ {(isScrolledDown || hasNew) && ( <LoadLatestBtn onPress={onPressLoadLatest} - label="Load new posts" + label={_(msg`Load new posts`)} showIndicator={hasNew} /> )} - <FAB - testID="composeFAB" - onPress={onPressCompose} - icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> + + {hasSession && ( + <FAB + testID="composeFAB" + onPress={onPressCompose} + icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + )} </View> ) -}) +} + +function useHeaderOffset() { + const {isDesktop, isTablet} = useWebMediaQueries() + const {fontScale} = useWindowDimensions() + const {hasSession} = useSession() + + if (isDesktop) { + return 0 + } + if (isTablet) { + if (hasSession) { + return 50 + } else { + return 0 + } + } + + if (hasSession) { + const navBarPad = 16 + const navBarText = 21 * fontScale + const tabBarPad = 20 + 3 // nav bar padding + border + const tabBarText = 16 * fontScale + const magic = 7 * fontScale + return navBarPad + navBarText + tabBarPad + tabBarText + magic + } else { + const navBarPad = 16 + const navBarText = 21 * fontScale + const magic = 4 * fontScale + return navBarPad + navBarText + magic + } +} diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 2c4335dc1..1f2af069b 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -6,43 +6,110 @@ import {RichText} from '../util/text/RichText' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {UserAvatar} from '../util/UserAvatar' -import {observer} from 'mobx-react-lite' -import {FeedSourceModel} from 'state/models/content/feed-source' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' -import {useStores} from 'state/index' import {pluralize} from 'lib/strings/helpers' import {AtUri} from '@atproto/api' import * as Toast from 'view/com/util/Toast' import {sanitizeHandle} from 'lib/strings/handles' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + usePinFeedMutation, + UsePreferencesQueryResponse, + usePreferencesQuery, + useSaveFeedMutation, + useRemoveFeedMutation, +} from '#/state/queries/preferences' +import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' -export const FeedSourceCard = observer(function FeedSourceCardImpl({ - item, +export function FeedSourceCard({ + feedUri, style, showSaveBtn = false, showDescription = false, showLikes = false, + LoadingComponent, + pinOnSave = false, }: { - item: FeedSourceModel + feedUri: string style?: StyleProp<ViewStyle> showSaveBtn?: boolean showDescription?: boolean showLikes?: boolean + LoadingComponent?: JSX.Element + pinOnSave?: boolean +}) { + const {data: preferences} = usePreferencesQuery() + const {data: feed} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!feed || !preferences) { + return LoadingComponent ? ( + LoadingComponent + ) : ( + <FeedLoadingPlaceholder style={{flex: 1}} /> + ) + } + + return ( + <FeedSourceCardLoaded + feed={feed} + preferences={preferences} + style={style} + showSaveBtn={showSaveBtn} + showDescription={showDescription} + showLikes={showLikes} + pinOnSave={pinOnSave} + /> + ) +} + +export function FeedSourceCardLoaded({ + feed, + preferences, + style, + showSaveBtn = false, + showDescription = false, + showLikes = false, + pinOnSave = false, +}: { + feed: FeedSourceInfo + preferences: UsePreferencesQueryResponse + style?: StyleProp<ViewStyle> + showSaveBtn?: boolean + showDescription?: boolean + showLikes?: boolean + pinOnSave?: boolean }) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const navigation = useNavigation<NavigationProp>() + const {openModal} = useModalControls() + + const {isPending: isSavePending, mutateAsync: saveFeed} = + useSaveFeedMutation() + const {isPending: isRemovePending, mutateAsync: removeFeed} = + useRemoveFeedMutation() + const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() + + const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed.uri)) const onToggleSaved = React.useCallback(async () => { - if (item.isSaved) { - store.shell.openModal({ + // Only feeds can be un/saved, lists are handled elsewhere + if (feed?.type !== 'feed') return + + if (isSaved) { + openModal({ name: 'confirm', - title: 'Remove from my feeds', - message: `Remove ${item.displayName} from my feeds?`, + title: _(msg`Remove from my feeds`), + message: _(msg`Remove ${feed.displayName} from my feeds?`), onPressConfirm: async () => { try { - await item.unsave() + await removeFeed({uri: feed.uri}) + // await item.unsave() Toast.show('Removed from my feeds') } catch (e) { Toast.show('There was an issue contacting your server') @@ -52,58 +119,67 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({ }) } else { try { - await item.save() + if (pinOnSave) { + await pinFeed({uri: feed.uri}) + } else { + await saveFeed({uri: feed.uri}) + } Toast.show('Added to my feeds') } catch (e) { Toast.show('There was an issue contacting your server') logger.error('Failed to save feed', {error: e}) } } - }, [store, item]) + }, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed]) + + if (!feed || !preferences) return null return ( <Pressable - testID={`feed-${item.displayName}`} + testID={`feed-${feed.displayName}`} accessibilityRole="button" style={[styles.container, pal.border, style]} onPress={() => { - if (item.type === 'feed-generator') { + if (feed.type === 'feed') { navigation.push('ProfileFeed', { - name: item.creatorDid, - rkey: new AtUri(item.uri).rkey, + name: feed.creatorDid, + rkey: new AtUri(feed.uri).rkey, }) - } else if (item.type === 'list') { + } else if (feed.type === 'list') { navigation.push('ProfileList', { - name: item.creatorDid, - rkey: new AtUri(item.uri).rkey, + name: feed.creatorDid, + rkey: new AtUri(feed.uri).rkey, }) } }} - key={item.uri}> + key={feed.uri}> <View style={[styles.headerContainer]}> <View style={[s.mr10]}> - <UserAvatar type="algo" size={36} avatar={item.avatar} /> + <UserAvatar type="algo" size={36} avatar={feed.avatar} /> </View> <View style={[styles.headerTextContainer]}> <Text style={[pal.text, s.bold]} numberOfLines={3}> - {item.displayName} + {feed.displayName} </Text> <Text style={[pal.textLight]} numberOfLines={3}> - by {sanitizeHandle(item.creatorHandle, '@')} + {feed.type === 'feed' ? 'Feed' : 'List'} by{' '} + {sanitizeHandle(feed.creatorHandle, '@')} </Text> </View> - {showSaveBtn && ( + + {showSaveBtn && feed.type === 'feed' && ( <View> <Pressable + disabled={isSavePending || isPinPending || isRemovePending} accessibilityRole="button" accessibilityLabel={ - item.isSaved ? 'Remove from my feeds' : 'Add to my feeds' + isSaved ? 'Remove from my feeds' : 'Add to my feeds' } accessibilityHint="" onPress={onToggleSaved} hitSlop={15} style={styles.btn}> - {item.isSaved ? ( + {isSaved ? ( <FontAwesomeIcon icon={['far', 'trash-can']} size={19} @@ -121,23 +197,23 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({ )} </View> - {showDescription && item.descriptionRT ? ( + {showDescription && feed.description ? ( <RichText style={[pal.textLight, styles.description]} - richText={item.descriptionRT} + richText={feed.description} numberOfLines={3} /> ) : null} - {showLikes ? ( + {showLikes && feed.type === 'feed' ? ( <Text type="sm-medium" style={[pal.text, pal.textLight]}> - Liked by {item.likeCount || 0}{' '} - {pluralize(item.likeCount || 0, 'user')} + Liked by {feed.likeCount || 0}{' '} + {pluralize(feed.likeCount || 0, 'user')} </Text> ) : null} </Pressable> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx new file mode 100644 index 000000000..618f4e5cd --- /dev/null +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -0,0 +1,222 @@ +import React, {MutableRefObject} from 'react' +import { + Dimensions, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {useQueryClient} from '@tanstack/react-query' +import {FlatList} from '../util/Views' +import {FeedSourceCardLoaded} from './FeedSourceCard' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens' +import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' +import {logger} from '#/logger' +import {Trans} from '@lingui/macro' +import {cleanError} from '#/lib/strings/errors' +import {useAnimatedScrollHandler} from 'react-native-reanimated' +import {useTheme} from '#/lib/ThemeContext' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {hydrateFeedGenerator} from '#/state/queries/feed' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' + +const LOADING = {_reactKey: '__loading__'} +const EMPTY = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +interface SectionRef { + scrollToTop: () => void +} + +interface ProfileFeedgensProps { + did: string + scrollElRef: MutableRefObject<FlatList<any> | null> + onScroll?: OnScrollHandler + scrollEventThrottle?: number + headerOffset: number + enabled?: boolean + style?: StyleProp<ViewStyle> + testID?: string +} + +export const ProfileFeedgens = React.forwardRef< + SectionRef, + ProfileFeedgensProps +>(function ProfileFeedgensImpl( + { + did, + scrollElRef, + onScroll, + scrollEventThrottle, + headerOffset, + enabled, + style, + testID, + }, + ref, +) { + const pal = usePalette('default') + const theme = useTheme() + const [isPTRing, setIsPTRing] = React.useState(false) + const opts = React.useMemo(() => ({enabled}), [enabled]) + const { + data, + isFetching, + isFetched, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileFeedgensQuery(did, opts) + const isEmpty = !isFetching && !data?.pages[0]?.feeds.length + const {data: preferences} = usePreferencesQuery() + + const items = React.useMemo(() => { + let items: any[] = [] + if (isError && isEmpty) { + items = items.concat([ERROR_ITEM]) + } + if (!isFetched && isFetching) { + items = items.concat([LOADING]) + } else if (isEmpty) { + items = items.concat([EMPTY]) + } else if (data?.pages) { + for (const page of data?.pages) { + items = items.concat(page.feeds.map(feed => hydrateFeedGenerator(feed))) + } + } + if (isError && !isEmpty) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + return items + }, [isError, isEmpty, isFetched, isFetching, data]) + + // events + // = + + const queryClient = useQueryClient() + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerOffset}) + queryClient.invalidateQueries({queryKey: RQKEY(did)}) + }, [scrollElRef, queryClient, headerOffset, did]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh feeds', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more feeds', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const onPressRetryLoadMore = React.useCallback(() => { + fetchNextPage() + }, [fetchNextPage]) + + // rendering + // = + + const renderItemInner = React.useCallback( + ({item}: {item: any}) => { + if (item === EMPTY) { + return ( + <View + testID="listsEmpty" + style={[{padding: 18, borderTopWidth: 1}, pal.border]}> + <Text style={pal.textLight}> + <Trans>You have no feeds.</Trans> + </Text> + </View> + ) + } else if (item === ERROR_ITEM) { + return ( + <ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} /> + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + <LoadMoreRetryBtn + label="There was an issue fetching your lists. Tap here to try again." + onPress={onPressRetryLoadMore} + /> + ) + } else if (item === LOADING) { + return <FeedLoadingPlaceholder /> + } + if (preferences) { + return ( + <FeedSourceCardLoaded + feed={item} + preferences={preferences} + style={styles.item} + showLikes + /> + ) + } + return null + }, + [error, refetch, onPressRetryLoadMore, pal, preferences], + ) + + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) + return ( + <View testID={testID} style={style}> + <FlatList + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={items} + keyExtractor={(item: any) => item._reactKey || item.uri} + renderItem={renderItemInner} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + } + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} + style={{paddingTop: headerOffset}} + onScroll={onScroll != null ? scrollHandler : undefined} + scrollEventThrottle={scrollEventThrottle} + indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight + onEndReached={onEndReached} + /> + </View> + ) +}) + +const styles = StyleSheet.create({ + item: { + paddingHorizontal: 18, + }, +}) |