From d00879e628145ec6ded18048be212d09c0227ba8 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 10 Dec 2024 20:57:53 +0000 Subject: Disambiguate feed component naming (#7040) * Rename posts/Feed* -> posts/PostFeed* * Rename notifications/Feed* -> notifications/NotificationFeed* --- src/components/StarterPack/Main/PostsList.tsx | 4 +- src/screens/Profile/Sections/Feed.tsx | 4 +- src/state/feed-feedback.tsx | 2 +- src/state/queries/post-feed.ts | 2 +- src/view/com/feeds/FeedPage.tsx | 4 +- src/view/com/notifications/Feed.tsx | 197 ----- src/view/com/notifications/FeedItem.tsx | 817 --------------------- src/view/com/notifications/NotificationFeed.tsx | 197 +++++ .../com/notifications/NotificationFeedItem.tsx | 817 +++++++++++++++++++++ src/view/com/posts/Feed.tsx | 598 --------------- src/view/com/posts/FeedErrorMessage.tsx | 279 ------- src/view/com/posts/FeedItem.tsx | 653 ---------------- src/view/com/posts/PostFeed.tsx | 598 +++++++++++++++ src/view/com/posts/PostFeedErrorMessage.tsx | 279 +++++++ src/view/com/posts/PostFeedItem.tsx | 653 ++++++++++++++++ src/view/screens/DebugMod.tsx | 8 +- src/view/screens/Notifications.tsx | 4 +- src/view/screens/ProfileFeed.tsx | 4 +- src/view/screens/ProfileList.tsx | 4 +- 19 files changed, 2562 insertions(+), 2562 deletions(-) delete mode 100644 src/view/com/notifications/Feed.tsx delete mode 100644 src/view/com/notifications/FeedItem.tsx create mode 100644 src/view/com/notifications/NotificationFeed.tsx create mode 100644 src/view/com/notifications/NotificationFeedItem.tsx delete mode 100644 src/view/com/posts/Feed.tsx delete mode 100644 src/view/com/posts/FeedErrorMessage.tsx delete mode 100644 src/view/com/posts/FeedItem.tsx create mode 100644 src/view/com/posts/PostFeed.tsx create mode 100644 src/view/com/posts/PostFeedErrorMessage.tsx create mode 100644 src/view/com/posts/PostFeedItem.tsx (limited to 'src') diff --git a/src/components/StarterPack/Main/PostsList.tsx b/src/components/StarterPack/Main/PostsList.tsx index a5275ae87..f706e0bff 100644 --- a/src/components/StarterPack/Main/PostsList.tsx +++ b/src/components/StarterPack/Main/PostsList.tsx @@ -5,7 +5,7 @@ import {useLingui} from '@lingui/react' import {isNative} from '#/platform/detection' import {FeedDescriptor} from '#/state/queries/post-feed' -import {Feed} from '#/view/com/posts/Feed' +import {PostFeed} from '#/view/com/posts/PostFeed' import {EmptyState} from '#/view/com/util/EmptyState' import {ListRef} from '#/view/com/util/List' import {SectionRef} from '#/screens/Profile/Sections/types' @@ -38,7 +38,7 @@ export const PostsList = React.forwardRef( return ( - - - void - onScrolledDownChange: (isScrolledDown: boolean) => void - ListHeaderComponent?: () => JSX.Element - overridePriorityNotifications?: boolean -}) { - const initialNumToRender = useInitialNumToRender() - - const [isPTRing, setIsPTRing] = React.useState(false) - const pal = usePalette('default') - - const {_} = useLingui() - const moderationOpts = useModerationOpts() - const {checkUnread} = useUnreadNotificationsApi() - const { - data, - isFetching, - isFetched, - isError, - error, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - } = useNotificationFeedQuery({ - enabled: !!moderationOpts, - overridePriorityNotifications, - }) - const isEmpty = !isFetching && !data?.pages[0]?.items.length - - const items = React.useMemo(() => { - let arr: any[] = [] - if (isFetched) { - if (isEmpty) { - arr = arr.concat([EMPTY_FEED_ITEM]) - } else if (data) { - for (const page of data?.pages) { - arr = arr.concat(page.items) - } - } - if (isError && !isEmpty) { - arr = arr.concat([LOAD_MORE_ERROR_ITEM]) - } - } else { - arr.push(LOADING_ITEM) - } - return arr - }, [isFetched, isError, isEmpty, data]) - - const onRefresh = React.useCallback(async () => { - try { - setIsPTRing(true) - await checkUnread({invalidate: true}) - } catch (err) { - logger.error('Failed to refresh notifications feed', { - message: err, - }) - } finally { - setIsPTRing(false) - } - }, [checkUnread, setIsPTRing]) - - const onEndReached = React.useCallback(async () => { - if (isFetching || !hasNextPage || isError) return - - try { - await fetchNextPage() - } catch (err) { - logger.error('Failed to load more notifications', {message: err}) - } - }, [isFetching, hasNextPage, isError, fetchNextPage]) - - const onPressRetryLoadMore = React.useCallback(() => { - fetchNextPage() - }, [fetchNextPage]) - - const renderItem = React.useCallback( - ({item, index}: ListRenderItemInfo) => { - if (item === EMPTY_FEED_ITEM) { - return ( - - ) - } else if (item === LOAD_MORE_ERROR_ITEM) { - return ( - - ) - } else if (item === LOADING_ITEM) { - return ( - - - - ) - } - return ( - - ) - }, - [moderationOpts, _, onPressRetryLoadMore, pal.border], - ) - - const FeedFooter = React.useCallback( - () => - isFetchingNextPage ? ( - - - - ) : ( - - ), - [isFetchingNextPage], - ) - - return ( - - {error && ( - - )} - item._reactKey} - renderItem={renderItem} - ListHeaderComponent={ListHeaderComponent} - ListFooterComponent={FeedFooter} - refreshing={isPTRing} - onRefresh={onRefresh} - onEndReached={onEndReached} - onEndReachedThreshold={2} - onScrolledDownChange={onScrolledDownChange} - contentContainerStyle={s.contentContainer} - // @ts-ignore our .web version only -prf - desktopFixedHeight - initialNumToRender={initialNumToRender} - windowSize={11} - sideBorders={false} - removeClippedSubviews={true} - /> - - ) -} - -const styles = StyleSheet.create({ - feedFooter: {paddingTop: 20}, - emptyState: {paddingVertical: 40}, -}) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx deleted file mode 100644 index b90f2ecd6..000000000 --- a/src/view/com/notifications/FeedItem.tsx +++ /dev/null @@ -1,817 +0,0 @@ -import React, { - memo, - type ReactElement, - useEffect, - useMemo, - useState, -} from 'react' -import { - Animated, - Pressable, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import { - AppBskyActorDefs, - AppBskyFeedDefs, - AppBskyFeedPost, - AppBskyGraphFollow, - moderateProfile, - ModerationDecision, - ModerationOpts, -} from '@atproto/api' -import {AtUri} from '@atproto/api' -import {TID} from '@atproto/common-web' -import {msg, Plural, plural, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' - -import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' -import {usePalette} from '#/lib/hooks/usePalette' -import {makeProfileLink} from '#/lib/routes/links' -import {NavigationProp} from '#/lib/routes/types' -import {forceLTR} from '#/lib/strings/bidi' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {sanitizeHandle} from '#/lib/strings/handles' -import {niceDate} from '#/lib/strings/time' -import {colors, s} from '#/lib/styles' -import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' -import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' -import {FeedNotification} from '#/state/queries/notifications/feed' -import {precacheProfile} from '#/state/queries/profile' -import {useAgent} from '#/state/session' -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' -import { - ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, - ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, -} from '#/components/icons/Chevron' -import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2' -import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' -import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' -import {StarterPack} from '#/components/icons/StarterPack' -import {Link as NewLink} from '#/components/Link' -import * as MediaPreview from '#/components/MediaPreview' -import {ProfileHoverCard} from '#/components/ProfileHoverCard' -import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' -import {SubtleWebHover} from '#/components/SubtleWebHover' -import {FeedSourceCard} from '../feeds/FeedSourceCard' -import {Post} from '../post/Post' -import {Link, TextLink} from '../util/Link' -import {formatCount} from '../util/numeric/format' -import {Text} from '../util/text/Text' -import {TimeElapsed} from '../util/TimeElapsed' -import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' - -const MAX_AUTHORS = 5 - -const EXPANDED_AUTHOR_EL_HEIGHT = 35 - -interface Author { - profile: AppBskyActorDefs.ProfileViewBasic - href: string - moderation: ModerationDecision -} - -let FeedItem = ({ - item, - moderationOpts, - hideTopBorder, -}: { - item: FeedNotification - moderationOpts: ModerationOpts - hideTopBorder?: boolean -}): React.ReactNode => { - const queryClient = useQueryClient() - const pal = usePalette('default') - const {_, i18n} = useLingui() - const t = useTheme() - const [isAuthorsExpanded, setAuthorsExpanded] = useState(false) - const itemHref = useMemo(() => { - if (item.type === 'post-like' || item.type === 'repost') { - if (item.subjectUri) { - const urip = new AtUri(item.subjectUri) - return `/profile/${urip.host}/post/${urip.rkey}` - } - } else if (item.type === 'follow') { - return makeProfileLink(item.notification.author) - } else if (item.type === 'reply') { - const urip = new AtUri(item.notification.uri) - return `/profile/${urip.host}/post/${urip.rkey}` - } else if ( - item.type === 'feedgen-like' || - item.type === 'starterpack-joined' - ) { - if (item.subjectUri) { - const urip = new AtUri(item.subjectUri) - return `/profile/${urip.host}/feed/${urip.rkey}` - } - } - return '' - }, [item]) - - const onToggleAuthorsExpanded = () => { - setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) - } - - const onBeforePress = React.useCallback(() => { - precacheProfile(queryClient, item.notification.author) - }, [queryClient, item.notification.author]) - - const authors: Author[] = useMemo(() => { - return [ - { - profile: item.notification.author, - href: makeProfileLink(item.notification.author), - moderation: moderateProfile(item.notification.author, moderationOpts), - }, - ...(item.additional?.map(({author}) => ({ - profile: author, - href: makeProfileLink(author), - moderation: moderateProfile(author, moderationOpts), - })) || []), - ] - }, [item, moderationOpts]) - - const [hover, setHover] = React.useState(false) - - if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') { - // don't render anything if the target post was deleted or unfindable - return - } - - if ( - item.type === 'reply' || - item.type === 'mention' || - item.type === 'quote' - ) { - if (!item.subject) { - return null - } - return ( - - - - ) - } - - const niceTimestamp = niceDate(i18n, item.notification.indexedAt) - const firstAuthor = authors[0] - const firstAuthorName = sanitizeDisplayName( - firstAuthor.profile.displayName || firstAuthor.profile.handle, - ) - const firstAuthorLink = ( - - {forceLTR(firstAuthorName)} - - } - disableMismatchWarning - /> - ) - const additionalAuthorsCount = authors.length - 1 - const hasMultipleAuthors = additionalAuthorsCount > 0 - const formattedAuthorsCount = hasMultipleAuthors - ? formatCount(i18n, additionalAuthorsCount) - : '' - - let a11yLabel = '' - let notificationContent: ReactElement - let icon = ( - - ) - - if (item.type === 'post-like') { - a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} liked your post`, - ) - : _(msg`${firstAuthorName} liked your post`) - notificationContent = hasMultipleAuthors ? ( - - {firstAuthorLink} and{' '} - - - {' '} - liked your post - - ) : ( - {firstAuthorLink} liked your post - ) - } else if (item.type === 'repost') { - a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} reposted your post`, - ) - : _(msg`${firstAuthorName} reposted your post`) - notificationContent = hasMultipleAuthors ? ( - - {firstAuthorLink} and{' '} - - - {' '} - reposted your post - - ) : ( - {firstAuthorLink} reposted your post - ) - icon = - } else if (item.type === 'follow') { - let isFollowBack = false - - if ( - item.notification.author.viewer?.following && - AppBskyGraphFollow.isRecord(item.notification.record) - ) { - let followingTimestamp - try { - const rkey = new AtUri(item.notification.author.viewer.following).rkey - followingTimestamp = TID.fromStr(rkey).timestamp() - } catch (e) { - // For some reason the following URI was invalid. Default to it not being a follow back. - console.error('Invalid following URI') - } - if (followingTimestamp) { - const followedTimestamp = - new Date(item.notification.record.createdAt).getTime() * 1000 - isFollowBack = followedTimestamp > followingTimestamp - } - } - - if (isFollowBack && !hasMultipleAuthors) { - /* - * Follow-backs are ungrouped, grouped follow-backs not supported atm, - * see `src/state/queries/notifications/util.ts` - */ - a11yLabel = _(msg`${firstAuthorName} followed you back`) - notificationContent = {firstAuthorLink} followed you back - } else { - a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} followed you`, - ) - : _(msg`${firstAuthorName} followed you`) - notificationContent = hasMultipleAuthors ? ( - - {firstAuthorLink} and{' '} - - - {' '} - followed you - - ) : ( - {firstAuthorLink} followed you - ) - } - icon = - } else if (item.type === 'feedgen-like') { - a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} liked your custom feed`, - ) - : _(msg`${firstAuthorName} liked your custom feed`) - notificationContent = hasMultipleAuthors ? ( - - {firstAuthorLink} and{' '} - - - {' '} - liked your custom feed - - ) : ( - {firstAuthorLink} liked your custom feed - ) - } else if (item.type === 'starterpack-joined') { - a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} signed up with your starter pack`, - ) - : _(msg`${firstAuthorName} signed up with your starter pack`) - notificationContent = hasMultipleAuthors ? ( - - {firstAuthorLink} and{' '} - - - {' '} - signed up with your starter pack - - ) : ( - {firstAuthorLink} signed up with your starter pack - ) - icon = ( - - - - ) - } else { - return null - } - a11yLabel += ` · ${niceTimestamp}` - - return ( - { - if (e.nativeEvent.actionName === 'activate') { - onBeforePress() - } - if (e.nativeEvent.actionName === 'toggleAuthorsExpanded') { - onToggleAuthorsExpanded() - } - }} - onPointerEnter={() => { - setHover(true) - }} - onPointerLeave={() => { - setHover(false) - }}> - - - {/* TODO: Prevent conditional rendering and move toward composable - notifications for clearer accessibility labeling */} - {icon} - - - - - - - {notificationContent} - - {({timeElapsed}) => ( - <> - {/* make sure there's whitespace around the middot -sfn */} - · - - {timeElapsed} - - - )} - - - - {item.type === 'post-like' || item.type === 'repost' ? ( - - ) : null} - {item.type === 'feedgen-like' && item.subjectUri ? ( - - ) : null} - {item.type === 'starterpack-joined' ? ( - - - - - - ) : null} - - - ) -} -FeedItem = memo(FeedItem) -export {FeedItem} - -function ExpandListPressable({ - hasMultipleAuthors, - children, - onToggleAuthorsExpanded, -}: { - hasMultipleAuthors: boolean - children: React.ReactNode - onToggleAuthorsExpanded: () => void -}) { - if (hasMultipleAuthors) { - return ( - - {children} - - ) - } else { - return <>{children} - } -} - -function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) { - const {_} = useLingui() - const agent = useAgent() - const navigation = useNavigation() - const [isLoading, setIsLoading] = React.useState(false) - - if ( - profile.associated?.chat?.allowIncoming === 'none' || - (profile.associated?.chat?.allowIncoming === 'following' && - !profile.viewer?.followedBy) - ) { - return null - } - - return ( - - ) -} - -function CondensedAuthorsList({ - visible, - authors, - onToggleAuthorsExpanded, - showDmButton = true, -}: { - visible: boolean - authors: Author[] - onToggleAuthorsExpanded: () => void - showDmButton?: boolean -}) { - const pal = usePalette('default') - const {_} = useLingui() - - if (!visible) { - return ( - - - - - Hide - - - - ) - } - if (authors.length === 1) { - return ( - - - {showDmButton ? : null} - - ) - } - return ( - - - {authors.slice(0, MAX_AUTHORS).map(author => ( - - - - ))} - {authors.length > MAX_AUTHORS ? ( - - +{authors.length - MAX_AUTHORS} - - ) : undefined} - - - - ) -} - -function ExpandedAuthorsList({ - visible, - authors, -}: { - visible: boolean - authors: Author[] -}) { - const {_} = useLingui() - const pal = usePalette('default') - const heightInterp = useAnimatedValue(visible ? 1 : 0) - const targetHeight = - authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/ - const heightStyle = { - height: Animated.multiply(heightInterp, targetHeight), - } - useEffect(() => { - Animated.timing(heightInterp, { - toValue: visible ? 1 : 0, - duration: 200, - useNativeDriver: false, - }).start() - }, [heightInterp, visible]) - - return ( - - {visible && - authors.map(author => ( - - - - - - - - - - {sanitizeDisplayName( - author.profile.displayName || author.profile.handle, - )} - {' '} - - {sanitizeHandle(author.profile.handle, '@')} - - - - - ))} - - ) -} - -function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { - const pal = usePalette('default') - if (post && AppBskyFeedPost.isRecord(post?.record)) { - const text = post.record.text - - return ( - <> - {text?.length > 0 && ( - - {text} - - )} - - - ) - } -} - -const styles = StyleSheet.create({ - pointer: isWeb - ? { - // @ts-ignore web only - cursor: 'pointer', - } - : {}, - - outer: { - padding: 10, - paddingRight: 15, - flexDirection: 'row', - }, - layoutIcon: { - width: 60, - alignItems: 'flex-end', - paddingTop: 2, - }, - icon: { - marginRight: 10, - marginTop: 4, - }, - layoutContent: { - flex: 1, - }, - avis: { - flexDirection: 'row', - alignItems: 'center', - }, - aviExtraCount: { - fontWeight: '600', - paddingLeft: 6, - }, - meta: { - flexDirection: 'row', - flexWrap: 'wrap', - paddingTop: 6, - paddingBottom: 2, - }, - postText: { - paddingBottom: 5, - color: colors.black, - }, - additionalPostImages: { - marginTop: 5, - marginLeft: 2, - opacity: 0.8, - }, - feedcard: { - borderRadius: 8, - paddingVertical: 12, - marginTop: 6, - }, - - addedContainer: { - paddingTop: 4, - paddingLeft: 36, - }, - expandedAuthorsTrigger: { - zIndex: 1, - }, - expandedAuthorsCloseBtn: { - flexDirection: 'row', - alignItems: 'center', - paddingTop: 10, - paddingBottom: 6, - }, - expandedAuthorsCloseBtnIcon: { - marginLeft: 4, - marginRight: 4, - }, - expandedAuthor: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 10, - height: EXPANDED_AUTHOR_EL_HEIGHT, - }, - expandedAuthorAvi: { - marginRight: 5, - }, -}) diff --git a/src/view/com/notifications/NotificationFeed.tsx b/src/view/com/notifications/NotificationFeed.tsx new file mode 100644 index 000000000..5168933ae --- /dev/null +++ b/src/view/com/notifications/NotificationFeed.tsx @@ -0,0 +1,197 @@ +import React from 'react' +import { + ActivityIndicator, + ListRenderItemInfo, + StyleSheet, + View, +} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {usePalette} from '#/lib/hooks/usePalette' +import {cleanError} from '#/lib/strings/errors' +import {s} from '#/lib/styles' +import {logger} from '#/logger' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' +import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread' +import {EmptyState} from '#/view/com/util/EmptyState' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' +import {List, ListRef} from '#/view/com/util/List' +import {NotificationFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' +import {NotificationFeedItem} from './NotificationFeedItem' + +const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} +const LOADING_ITEM = {_reactKey: '__loading__'} + +export function NotificationFeed({ + scrollElRef, + onPressTryAgain, + onScrolledDownChange, + ListHeaderComponent, + overridePriorityNotifications, +}: { + scrollElRef?: ListRef + onPressTryAgain?: () => void + onScrolledDownChange: (isScrolledDown: boolean) => void + ListHeaderComponent?: () => JSX.Element + overridePriorityNotifications?: boolean +}) { + const initialNumToRender = useInitialNumToRender() + + const [isPTRing, setIsPTRing] = React.useState(false) + const pal = usePalette('default') + + const {_} = useLingui() + const moderationOpts = useModerationOpts() + const {checkUnread} = useUnreadNotificationsApi() + const { + data, + isFetching, + isFetched, + isError, + error, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useNotificationFeedQuery({ + enabled: !!moderationOpts, + overridePriorityNotifications, + }) + const isEmpty = !isFetching && !data?.pages[0]?.items.length + + const items = React.useMemo(() => { + let arr: any[] = [] + if (isFetched) { + if (isEmpty) { + arr = arr.concat([EMPTY_FEED_ITEM]) + } else if (data) { + for (const page of data?.pages) { + arr = arr.concat(page.items) + } + } + if (isError && !isEmpty) { + arr = arr.concat([LOAD_MORE_ERROR_ITEM]) + } + } else { + arr.push(LOADING_ITEM) + } + return arr + }, [isFetched, isError, isEmpty, data]) + + const onRefresh = React.useCallback(async () => { + try { + setIsPTRing(true) + await checkUnread({invalidate: true}) + } catch (err) { + logger.error('Failed to refresh notifications feed', { + message: err, + }) + } finally { + setIsPTRing(false) + } + }, [checkUnread, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more notifications', {message: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const onPressRetryLoadMore = React.useCallback(() => { + fetchNextPage() + }, [fetchNextPage]) + + const renderItem = React.useCallback( + ({item, index}: ListRenderItemInfo) => { + if (item === EMPTY_FEED_ITEM) { + return ( + + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + + ) + } else if (item === LOADING_ITEM) { + return ( + + + + ) + } + return ( + + ) + }, + [moderationOpts, _, onPressRetryLoadMore, pal.border], + ) + + const FeedFooter = React.useCallback( + () => + isFetchingNextPage ? ( + + + + ) : ( + + ), + [isFetchingNextPage], + ) + + return ( + + {error && ( + + )} + item._reactKey} + renderItem={renderItem} + ListHeaderComponent={ListHeaderComponent} + ListFooterComponent={FeedFooter} + refreshing={isPTRing} + onRefresh={onRefresh} + onEndReached={onEndReached} + onEndReachedThreshold={2} + onScrolledDownChange={onScrolledDownChange} + contentContainerStyle={s.contentContainer} + // @ts-ignore our .web version only -prf + desktopFixedHeight + initialNumToRender={initialNumToRender} + windowSize={11} + sideBorders={false} + removeClippedSubviews={true} + /> + + ) +} + +const styles = StyleSheet.create({ + feedFooter: {paddingTop: 20}, + emptyState: {paddingVertical: 40}, +}) diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx new file mode 100644 index 000000000..4902e66bc --- /dev/null +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -0,0 +1,817 @@ +import React, { + memo, + type ReactElement, + useEffect, + useMemo, + useState, +} from 'react' +import { + Animated, + Pressable, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import { + AppBskyActorDefs, + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyGraphFollow, + moderateProfile, + ModerationDecision, + ModerationOpts, +} from '@atproto/api' +import {AtUri} from '@atproto/api' +import {TID} from '@atproto/common-web' +import {msg, Plural, plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' +import {usePalette} from '#/lib/hooks/usePalette' +import {makeProfileLink} from '#/lib/routes/links' +import {NavigationProp} from '#/lib/routes/types' +import {forceLTR} from '#/lib/strings/bidi' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {niceDate} from '#/lib/strings/time' +import {colors, s} from '#/lib/styles' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' +import {FeedNotification} from '#/state/queries/notifications/feed' +import {precacheProfile} from '#/state/queries/profile' +import {useAgent} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, + ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, +} from '#/components/icons/Chevron' +import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2' +import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' +import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' +import {StarterPack} from '#/components/icons/StarterPack' +import {Link as NewLink} from '#/components/Link' +import * as MediaPreview from '#/components/MediaPreview' +import {ProfileHoverCard} from '#/components/ProfileHoverCard' +import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import {FeedSourceCard} from '../feeds/FeedSourceCard' +import {Post} from '../post/Post' +import {Link, TextLink} from '../util/Link' +import {formatCount} from '../util/numeric/format' +import {Text} from '../util/text/Text' +import {TimeElapsed} from '../util/TimeElapsed' +import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' + +const MAX_AUTHORS = 5 + +const EXPANDED_AUTHOR_EL_HEIGHT = 35 + +interface Author { + profile: AppBskyActorDefs.ProfileViewBasic + href: string + moderation: ModerationDecision +} + +let NotificationFeedItem = ({ + item, + moderationOpts, + hideTopBorder, +}: { + item: FeedNotification + moderationOpts: ModerationOpts + hideTopBorder?: boolean +}): React.ReactNode => { + const queryClient = useQueryClient() + const pal = usePalette('default') + const {_, i18n} = useLingui() + const t = useTheme() + const [isAuthorsExpanded, setAuthorsExpanded] = useState(false) + const itemHref = useMemo(() => { + if (item.type === 'post-like' || item.type === 'repost') { + if (item.subjectUri) { + const urip = new AtUri(item.subjectUri) + return `/profile/${urip.host}/post/${urip.rkey}` + } + } else if (item.type === 'follow') { + return makeProfileLink(item.notification.author) + } else if (item.type === 'reply') { + const urip = new AtUri(item.notification.uri) + return `/profile/${urip.host}/post/${urip.rkey}` + } else if ( + item.type === 'feedgen-like' || + item.type === 'starterpack-joined' + ) { + if (item.subjectUri) { + const urip = new AtUri(item.subjectUri) + return `/profile/${urip.host}/feed/${urip.rkey}` + } + } + return '' + }, [item]) + + const onToggleAuthorsExpanded = () => { + setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) + } + + const onBeforePress = React.useCallback(() => { + precacheProfile(queryClient, item.notification.author) + }, [queryClient, item.notification.author]) + + const authors: Author[] = useMemo(() => { + return [ + { + profile: item.notification.author, + href: makeProfileLink(item.notification.author), + moderation: moderateProfile(item.notification.author, moderationOpts), + }, + ...(item.additional?.map(({author}) => ({ + profile: author, + href: makeProfileLink(author), + moderation: moderateProfile(author, moderationOpts), + })) || []), + ] + }, [item, moderationOpts]) + + const [hover, setHover] = React.useState(false) + + if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') { + // don't render anything if the target post was deleted or unfindable + return + } + + if ( + item.type === 'reply' || + item.type === 'mention' || + item.type === 'quote' + ) { + if (!item.subject) { + return null + } + return ( + + + + ) + } + + const niceTimestamp = niceDate(i18n, item.notification.indexedAt) + const firstAuthor = authors[0] + const firstAuthorName = sanitizeDisplayName( + firstAuthor.profile.displayName || firstAuthor.profile.handle, + ) + const firstAuthorLink = ( + + {forceLTR(firstAuthorName)} + + } + disableMismatchWarning + /> + ) + const additionalAuthorsCount = authors.length - 1 + const hasMultipleAuthors = additionalAuthorsCount > 0 + const formattedAuthorsCount = hasMultipleAuthors + ? formatCount(i18n, additionalAuthorsCount) + : '' + + let a11yLabel = '' + let notificationContent: ReactElement + let icon = ( + + ) + + if (item.type === 'post-like') { + a11yLabel = hasMultipleAuthors + ? _( + msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} liked your post`, + ) + : _(msg`${firstAuthorName} liked your post`) + notificationContent = hasMultipleAuthors ? ( + + {firstAuthorLink} and{' '} + + + {' '} + liked your post + + ) : ( + {firstAuthorLink} liked your post + ) + } else if (item.type === 'repost') { + a11yLabel = hasMultipleAuthors + ? _( + msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} reposted your post`, + ) + : _(msg`${firstAuthorName} reposted your post`) + notificationContent = hasMultipleAuthors ? ( + + {firstAuthorLink} and{' '} + + + {' '} + reposted your post + + ) : ( + {firstAuthorLink} reposted your post + ) + icon = + } else if (item.type === 'follow') { + let isFollowBack = false + + if ( + item.notification.author.viewer?.following && + AppBskyGraphFollow.isRecord(item.notification.record) + ) { + let followingTimestamp + try { + const rkey = new AtUri(item.notification.author.viewer.following).rkey + followingTimestamp = TID.fromStr(rkey).timestamp() + } catch (e) { + // For some reason the following URI was invalid. Default to it not being a follow back. + console.error('Invalid following URI') + } + if (followingTimestamp) { + const followedTimestamp = + new Date(item.notification.record.createdAt).getTime() * 1000 + isFollowBack = followedTimestamp > followingTimestamp + } + } + + if (isFollowBack && !hasMultipleAuthors) { + /* + * Follow-backs are ungrouped, grouped follow-backs not supported atm, + * see `src/state/queries/notifications/util.ts` + */ + a11yLabel = _(msg`${firstAuthorName} followed you back`) + notificationContent = {firstAuthorLink} followed you back + } else { + a11yLabel = hasMultipleAuthors + ? _( + msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} followed you`, + ) + : _(msg`${firstAuthorName} followed you`) + notificationContent = hasMultipleAuthors ? ( + + {firstAuthorLink} and{' '} + + + {' '} + followed you + + ) : ( + {firstAuthorLink} followed you + ) + } + icon = + } else if (item.type === 'feedgen-like') { + a11yLabel = hasMultipleAuthors + ? _( + msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} liked your custom feed`, + ) + : _(msg`${firstAuthorName} liked your custom feed`) + notificationContent = hasMultipleAuthors ? ( + + {firstAuthorLink} and{' '} + + + {' '} + liked your custom feed + + ) : ( + {firstAuthorLink} liked your custom feed + ) + } else if (item.type === 'starterpack-joined') { + a11yLabel = hasMultipleAuthors + ? _( + msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} signed up with your starter pack`, + ) + : _(msg`${firstAuthorName} signed up with your starter pack`) + notificationContent = hasMultipleAuthors ? ( + + {firstAuthorLink} and{' '} + + + {' '} + signed up with your starter pack + + ) : ( + {firstAuthorLink} signed up with your starter pack + ) + icon = ( + + + + ) + } else { + return null + } + a11yLabel += ` · ${niceTimestamp}` + + return ( + { + if (e.nativeEvent.actionName === 'activate') { + onBeforePress() + } + if (e.nativeEvent.actionName === 'toggleAuthorsExpanded') { + onToggleAuthorsExpanded() + } + }} + onPointerEnter={() => { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + + + {/* TODO: Prevent conditional rendering and move toward composable + notifications for clearer accessibility labeling */} + {icon} + + + + + + + {notificationContent} + + {({timeElapsed}) => ( + <> + {/* make sure there's whitespace around the middot -sfn */} + · + + {timeElapsed} + + + )} + + + + {item.type === 'post-like' || item.type === 'repost' ? ( + + ) : null} + {item.type === 'feedgen-like' && item.subjectUri ? ( + + ) : null} + {item.type === 'starterpack-joined' ? ( + + + + + + ) : null} + + + ) +} +NotificationFeedItem = memo(NotificationFeedItem) +export {NotificationFeedItem} + +function ExpandListPressable({ + hasMultipleAuthors, + children, + onToggleAuthorsExpanded, +}: { + hasMultipleAuthors: boolean + children: React.ReactNode + onToggleAuthorsExpanded: () => void +}) { + if (hasMultipleAuthors) { + return ( + + {children} + + ) + } else { + return <>{children} + } +} + +function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) { + const {_} = useLingui() + const agent = useAgent() + const navigation = useNavigation() + const [isLoading, setIsLoading] = React.useState(false) + + if ( + profile.associated?.chat?.allowIncoming === 'none' || + (profile.associated?.chat?.allowIncoming === 'following' && + !profile.viewer?.followedBy) + ) { + return null + } + + return ( + + ) +} + +function CondensedAuthorsList({ + visible, + authors, + onToggleAuthorsExpanded, + showDmButton = true, +}: { + visible: boolean + authors: Author[] + onToggleAuthorsExpanded: () => void + showDmButton?: boolean +}) { + const pal = usePalette('default') + const {_} = useLingui() + + if (!visible) { + return ( + + + + + Hide + + + + ) + } + if (authors.length === 1) { + return ( + + + {showDmButton ? : null} + + ) + } + return ( + + + {authors.slice(0, MAX_AUTHORS).map(author => ( + + + + ))} + {authors.length > MAX_AUTHORS ? ( + + +{authors.length - MAX_AUTHORS} + + ) : undefined} + + + + ) +} + +function ExpandedAuthorsList({ + visible, + authors, +}: { + visible: boolean + authors: Author[] +}) { + const {_} = useLingui() + const pal = usePalette('default') + const heightInterp = useAnimatedValue(visible ? 1 : 0) + const targetHeight = + authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/ + const heightStyle = { + height: Animated.multiply(heightInterp, targetHeight), + } + useEffect(() => { + Animated.timing(heightInterp, { + toValue: visible ? 1 : 0, + duration: 200, + useNativeDriver: false, + }).start() + }, [heightInterp, visible]) + + return ( + + {visible && + authors.map(author => ( + + + + + + + + + + {sanitizeDisplayName( + author.profile.displayName || author.profile.handle, + )} + {' '} + + {sanitizeHandle(author.profile.handle, '@')} + + + + + ))} + + ) +} + +function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { + const pal = usePalette('default') + if (post && AppBskyFeedPost.isRecord(post?.record)) { + const text = post.record.text + + return ( + <> + {text?.length > 0 && ( + + {text} + + )} + + + ) + } +} + +const styles = StyleSheet.create({ + pointer: isWeb + ? { + // @ts-ignore web only + cursor: 'pointer', + } + : {}, + + outer: { + padding: 10, + paddingRight: 15, + flexDirection: 'row', + }, + layoutIcon: { + width: 60, + alignItems: 'flex-end', + paddingTop: 2, + }, + icon: { + marginRight: 10, + marginTop: 4, + }, + layoutContent: { + flex: 1, + }, + avis: { + flexDirection: 'row', + alignItems: 'center', + }, + aviExtraCount: { + fontWeight: '600', + paddingLeft: 6, + }, + meta: { + flexDirection: 'row', + flexWrap: 'wrap', + paddingTop: 6, + paddingBottom: 2, + }, + postText: { + paddingBottom: 5, + color: colors.black, + }, + additionalPostImages: { + marginTop: 5, + marginLeft: 2, + opacity: 0.8, + }, + feedcard: { + borderRadius: 8, + paddingVertical: 12, + marginTop: 6, + }, + + addedContainer: { + paddingTop: 4, + paddingLeft: 36, + }, + expandedAuthorsTrigger: { + zIndex: 1, + }, + expandedAuthorsCloseBtn: { + flexDirection: 'row', + alignItems: 'center', + paddingTop: 10, + paddingBottom: 6, + }, + expandedAuthorsCloseBtnIcon: { + marginLeft: 4, + marginRight: 4, + }, + expandedAuthor: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 10, + height: EXPANDED_AUTHOR_EL_HEIGHT, + }, + expandedAuthorAvi: { + marginRight: 5, + }, +}) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx deleted file mode 100644 index fb5484919..000000000 --- a/src/view/com/posts/Feed.tsx +++ /dev/null @@ -1,598 +0,0 @@ -import React, {memo} from 'react' -import { - ActivityIndicator, - AppState, - Dimensions, - ListRenderItemInfo, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useQueryClient} from '@tanstack/react-query' - -import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' -import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' -import {logEvent} from '#/lib/statsig/statsig' -import {useTheme} from '#/lib/ThemeContext' -import {logger} from '#/logger' -import {isIOS, isWeb} from '#/platform/detection' -import {listenPostCreated} from '#/state/events' -import {useFeedFeedbackContext} from '#/state/feed-feedback' -import {STALE} from '#/state/queries' -import { - FeedDescriptor, - FeedParams, - FeedPostSlice, - pollLatest, - RQKEY, - usePostFeedQuery, -} from '#/state/queries/post-feed' -import {useSession} from '#/state/session' -import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' -import {List, ListRef} from '../util/List' -import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' -import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' -import {FeedErrorMessage} from './FeedErrorMessage' -import {FeedItem} from './FeedItem' -import {FeedShutdownMsg} from './FeedShutdownMsg' -import {ViewFullThread} from './ViewFullThread' - -type FeedRow = - | { - type: 'loading' - key: string - } - | { - type: 'empty' - key: string - } - | { - type: 'error' - key: string - } - | { - type: 'loadMoreError' - key: string - } - | { - type: 'feedShutdownMsg' - key: string - } - | { - type: 'slice' - key: string - slice: FeedPostSlice - } - | { - type: 'sliceItem' - key: string - slice: FeedPostSlice - indexInSlice: number - showReplyTo: boolean - } - | { - type: 'sliceViewFullThread' - key: string - uri: string - } - | { - type: 'interstitialFollows' - key: string - } - | { - type: 'interstitialProgressGuide' - key: string - } - -export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null { - if (feedRow.type === 'sliceItem') { - return feedRow.slice - } else { - return null - } -} - -// DISABLED need to check if this is causing random feed refreshes -prf -// const REFRESH_AFTER = STALE.HOURS.ONE -const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY - -let Feed = ({ - feed, - feedParams, - ignoreFilterFor, - style, - enabled, - pollInterval, - disablePoll, - scrollElRef, - onScrolledDownChange, - onHasNew, - renderEmptyState, - renderEndOfFeed, - testID, - headerOffset = 0, - progressViewOffset, - desktopFixedHeightOffset, - ListHeaderComponent, - extraData, - savedFeedConfig, - initialNumToRender: initialNumToRenderOverride, -}: { - feed: FeedDescriptor - feedParams?: FeedParams - ignoreFilterFor?: string - style?: StyleProp - enabled?: boolean - pollInterval?: number - disablePoll?: boolean - scrollElRef?: ListRef - onHasNew?: (v: boolean) => void - onScrolledDownChange?: (isScrolledDown: boolean) => void - renderEmptyState: () => JSX.Element - renderEndOfFeed?: () => JSX.Element - testID?: string - headerOffset?: number - progressViewOffset?: number - desktopFixedHeightOffset?: number - ListHeaderComponent?: () => JSX.Element - extraData?: any - savedFeedConfig?: AppBskyActorDefs.SavedFeed - initialNumToRender?: number -}): React.ReactNode => { - const theme = useTheme() - const {_} = useLingui() - const queryClient = useQueryClient() - const {currentAccount, hasSession} = useSession() - const initialNumToRender = useInitialNumToRender() - const feedFeedback = useFeedFeedbackContext() - const [isPTRing, setIsPTRing] = React.useState(false) - const checkForNewRef = React.useRef<(() => void) | null>(null) - const lastFetchRef = React.useRef(Date.now()) - const [feedType, feedUri, feedTab] = feed.split('|') - - const opts = React.useMemo( - () => ({enabled, ignoreFilterFor}), - [enabled, ignoreFilterFor], - ) - const { - data, - isFetching, - isFetched, - isError, - error, - refetch, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - } = usePostFeedQuery(feed, feedParams, opts) - const lastFetchedAt = data?.pages[0].fetchedAt - if (lastFetchedAt) { - lastFetchRef.current = lastFetchedAt - } - const isEmpty = React.useMemo( - () => !isFetching && !data?.pages?.some(page => page.slices.length), - [isFetching, data], - ) - - const checkForNew = React.useCallback(async () => { - if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) { - return - } - try { - if (await pollLatest(data.pages[0])) { - onHasNew(true) - } - } catch (e) { - logger.error('Poll latest failed', {feed, message: String(e)}) - } - }, [feed, data, isFetching, onHasNew, enabled, disablePoll]) - - const myDid = currentAccount?.did || '' - const onPostCreated = React.useCallback(() => { - // NOTE - // only invalidate if there's 1 page - // more than 1 page can trigger some UI freakouts on iOS and android - // -prf - if ( - data?.pages.length === 1 && - (feed === 'following' || - feed === `author|${myDid}|posts_and_author_threads`) - ) { - queryClient.invalidateQueries({queryKey: RQKEY(feed)}) - } - }, [queryClient, feed, data, myDid]) - React.useEffect(() => { - return listenPostCreated(onPostCreated) - }, [onPostCreated]) - - React.useEffect(() => { - // we store the interval handler in a ref to avoid needless - // reassignments in other effects - checkForNewRef.current = checkForNew - }, [checkForNew]) - React.useEffect(() => { - if (enabled && !disablePoll) { - const timeSinceFirstLoad = Date.now() - lastFetchRef.current - // DISABLED need to check if this is causing random feed refreshes -prf - /*if (timeSinceFirstLoad > REFRESH_AFTER) { - // do a full refresh - scrollElRef?.current?.scrollToOffset({offset: 0, animated: false}) - queryClient.resetQueries({queryKey: RQKEY(feed)}) - } else*/ if ( - timeSinceFirstLoad > CHECK_LATEST_AFTER && - checkForNewRef.current - ) { - // check for new on enable (aka on focus) - checkForNewRef.current() - } - } - }, [enabled, disablePoll, feed, queryClient, scrollElRef]) - React.useEffect(() => { - let cleanup1: () => void | undefined, cleanup2: () => void | undefined - const subscription = AppState.addEventListener('change', nextAppState => { - // check for new on app foreground - if (nextAppState === 'active') { - checkForNewRef.current?.() - } - }) - cleanup1 = () => subscription.remove() - if (pollInterval) { - // check for new on interval - const i = setInterval(() => checkForNewRef.current?.(), pollInterval) - cleanup2 = () => clearInterval(i) - } - return () => { - cleanup1?.() - cleanup2?.() - } - }, [pollInterval]) - - const feedItems: FeedRow[] = React.useMemo(() => { - let feedKind: 'following' | 'discover' | 'profile' | undefined - if (feedType === 'following') { - feedKind = 'following' - } else if (feedUri === DISCOVER_FEED_URI) { - feedKind = 'discover' - } else if ( - feedType === 'author' && - (feedTab === 'posts_and_author_threads' || - feedTab === 'posts_with_replies') - ) { - feedKind = 'profile' - } - - let arr: FeedRow[] = [] - if (KNOWN_SHUTDOWN_FEEDS.includes(feedUri)) { - arr.push({ - type: 'feedShutdownMsg', - key: 'feedShutdownMsg', - }) - } - if (isFetched) { - if (isError && isEmpty) { - arr.push({ - type: 'error', - key: 'error', - }) - } else if (isEmpty) { - arr.push({ - type: 'empty', - key: 'empty', - }) - } else if (data) { - let sliceIndex = -1 - for (const page of data?.pages) { - for (const slice of page.slices) { - sliceIndex++ - - if (hasSession) { - if (feedKind === 'discover') { - if (sliceIndex === 0) { - arr.push({ - type: 'interstitialProgressGuide', - key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, - }) - } else if (sliceIndex === 20) { - arr.push({ - type: 'interstitialFollows', - key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, - }) - } - } else if (feedKind === 'profile') { - if (sliceIndex === 5) { - arr.push({ - type: 'interstitialFollows', - key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, - }) - } - } - } - - if (slice.isIncompleteThread && slice.items.length >= 3) { - const beforeLast = slice.items.length - 2 - const last = slice.items.length - 1 - arr.push({ - type: 'sliceItem', - key: slice.items[0]._reactKey, - slice: slice, - indexInSlice: 0, - showReplyTo: false, - }) - arr.push({ - type: 'sliceViewFullThread', - key: slice._reactKey + '-viewFullThread', - uri: slice.items[0].uri, - }) - arr.push({ - type: 'sliceItem', - key: slice.items[beforeLast]._reactKey, - slice: slice, - indexInSlice: beforeLast, - showReplyTo: - slice.items[beforeLast].parentAuthor?.did !== - slice.items[beforeLast].post.author.did, - }) - arr.push({ - type: 'sliceItem', - key: slice.items[last]._reactKey, - slice: slice, - indexInSlice: last, - showReplyTo: false, - }) - } else { - for (let i = 0; i < slice.items.length; i++) { - arr.push({ - type: 'sliceItem', - key: slice.items[i]._reactKey, - slice: slice, - indexInSlice: i, - showReplyTo: i === 0, - }) - } - } - } - } - } - if (isError && !isEmpty) { - arr.push({ - type: 'loadMoreError', - key: 'loadMoreError', - }) - } - } else { - arr.push({ - type: 'loading', - key: 'loading', - }) - } - - return arr - }, [ - isFetched, - isError, - isEmpty, - lastFetchedAt, - data, - feedType, - feedUri, - feedTab, - hasSession, - ]) - - // events - // = - - const onRefresh = React.useCallback(async () => { - logEvent('feed:refresh', { - feedType: feedType, - feedUrl: feed, - reason: 'pull-to-refresh', - }) - setIsPTRing(true) - try { - await refetch() - onHasNew?.(false) - } catch (err) { - logger.error('Failed to refresh posts feed', {message: err}) - } - setIsPTRing(false) - }, [refetch, setIsPTRing, onHasNew, feed, feedType]) - - const onEndReached = React.useCallback(async () => { - if (isFetching || !hasNextPage || isError) return - - logEvent('feed:endReached', { - feedType: feedType, - feedUrl: feed, - itemCount: feedItems.length, - }) - try { - await fetchNextPage() - } catch (err) { - logger.error('Failed to load more posts', {message: err}) - } - }, [ - isFetching, - hasNextPage, - isError, - fetchNextPage, - feed, - feedType, - feedItems.length, - ]) - - const onPressTryAgain = React.useCallback(() => { - refetch() - onHasNew?.(false) - }, [refetch, onHasNew]) - - const onPressRetryLoadMore = React.useCallback(() => { - fetchNextPage() - }, [fetchNextPage]) - - // rendering - // = - - const renderItem = React.useCallback( - ({item: row, index: rowIndex}: ListRenderItemInfo) => { - if (row.type === 'empty') { - return renderEmptyState() - } else if (row.type === 'error') { - return ( - - ) - } else if (row.type === 'loadMoreError') { - return ( - - ) - } else if (row.type === 'loading') { - return - } else if (row.type === 'feedShutdownMsg') { - return - } else if (row.type === 'interstitialFollows') { - return - } else if (row.type === 'interstitialProgressGuide') { - return - } else if (row.type === 'sliceItem') { - const slice = row.slice - if (slice.isFallbackMarker) { - // HACK - // tell the user we fell back to discover - // see home.ts (feed api) for more info - // -prf - return - } - const indexInSlice = row.indexInSlice - const item = slice.items[indexInSlice] - return ( - - ) - } else if (row.type === 'sliceViewFullThread') { - return - } else { - return null - } - }, - [ - renderEmptyState, - feed, - error, - onPressTryAgain, - savedFeedConfig, - _, - onPressRetryLoadMore, - feedUri, - ], - ) - - const shouldRenderEndOfFeed = - !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed - const FeedFooter = React.useCallback(() => { - /** - * A bit of padding at the bottom of the feed as you scroll and when you - * reach the end, so that content isn't cut off by the bottom of the - * screen. - */ - const offset = Math.max(headerOffset, 32) * (isWeb ? 1 : 2) - - return isFetchingNextPage ? ( - - - - - ) : shouldRenderEndOfFeed ? ( - {renderEndOfFeed()} - ) : ( - - ) - }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset]) - - return ( - - item.key} - renderItem={renderItem} - ListFooterComponent={FeedFooter} - ListHeaderComponent={ListHeaderComponent} - refreshing={isPTRing} - onRefresh={onRefresh} - headerOffset={headerOffset} - progressViewOffset={progressViewOffset} - contentContainerStyle={{ - minHeight: Dimensions.get('window').height * 1.5, - }} - onScrolledDownChange={onScrolledDownChange} - indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} - onEndReached={onEndReached} - onEndReachedThreshold={2} // number of posts left to trigger load more - removeClippedSubviews={true} - extraData={extraData} - // @ts-ignore our .web version only -prf - desktopFixedHeight={ - desktopFixedHeightOffset ? desktopFixedHeightOffset : true - } - initialNumToRender={initialNumToRenderOverride ?? initialNumToRender} - windowSize={9} - maxToRenderPerBatch={isIOS ? 5 : 1} - updateCellsBatchingPeriod={40} - onItemSeen={feedFeedback.onItemSeen} - /> - - ) -} -Feed = memo(Feed) -export {Feed} - -const styles = StyleSheet.create({ - feedFooter: {paddingTop: 20}, -}) - -function isThreadParentAt(arr: Array, i: number) { - if (arr.length === 1) { - return false - } - return i < arr.length - 1 -} - -function isThreadChildAt(arr: Array, i: number) { - if (arr.length === 1) { - return false - } - return i > 0 -} diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx deleted file mode 100644 index a58216233..000000000 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {AppBskyActorDefs, AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api' -import {msg as msgLingui, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' - -import {usePalette} from '#/lib/hooks/usePalette' -import {NavigationProp} from '#/lib/routes/types' -import {cleanError} from '#/lib/strings/errors' -import {logger} from '#/logger' -import {FeedDescriptor} from '#/state/queries/post-feed' -import {useRemoveFeedMutation} from '#/state/queries/preferences' -import * as Prompt from '#/components/Prompt' -import {EmptyState} from '../util/EmptyState' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {Button} from '../util/forms/Button' -import {Text} from '../util/text/Text' -import * as Toast from '../util/Toast' - -export enum KnownError { - Block = 'Block', - FeedgenDoesNotExist = 'FeedgenDoesNotExist', - FeedgenMisconfigured = 'FeedgenMisconfigured', - FeedgenBadResponse = 'FeedgenBadResponse', - FeedgenOffline = 'FeedgenOffline', - FeedgenUnknown = 'FeedgenUnknown', - FeedSignedInOnly = 'FeedSignedInOnly', - FeedTooManyRequests = 'FeedTooManyRequests', - Unknown = 'Unknown', -} - -export function FeedErrorMessage({ - feedDesc, - error, - onPressTryAgain, - savedFeedConfig, -}: { - feedDesc: FeedDescriptor - error?: Error - onPressTryAgain: () => void - savedFeedConfig?: AppBskyActorDefs.SavedFeed -}) { - const {_: _l} = useLingui() - const knownError = React.useMemo( - () => detectKnownError(feedDesc, error), - [feedDesc, error], - ) - if ( - typeof knownError !== 'undefined' && - knownError !== KnownError.Unknown && - feedDesc.startsWith('feedgen') - ) { - return ( - - ) - } - - if (knownError === KnownError.Block) { - return ( - - ) - } - - return ( - - ) -} - -function FeedgenErrorMessage({ - feedDesc, - knownError, - rawError, - savedFeedConfig, -}: { - feedDesc: FeedDescriptor - knownError: KnownError - rawError?: Error - savedFeedConfig?: AppBskyActorDefs.SavedFeed -}) { - const pal = usePalette('default') - const {_: _l} = useLingui() - const navigation = useNavigation() - const msg = React.useMemo( - () => - ({ - [KnownError.Unknown]: '', - [KnownError.Block]: '', - [KnownError.FeedgenDoesNotExist]: _l( - msgLingui`Hmm, we're having trouble finding this feed. It may have been deleted.`, - ), - [KnownError.FeedgenMisconfigured]: _l( - msgLingui`Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.`, - ), - [KnownError.FeedgenBadResponse]: _l( - msgLingui`Hmm, the feed server gave a bad response. Please let the feed owner know about this issue.`, - ), - [KnownError.FeedgenOffline]: _l( - msgLingui`Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.`, - ), - [KnownError.FeedSignedInOnly]: _l( - msgLingui`This content is not viewable without a Bluesky account.`, - ), - [KnownError.FeedgenUnknown]: _l( - msgLingui`Hmm, some kind of issue occurred when contacting the feed server. Please let the feed owner know about this issue.`, - ), - [KnownError.FeedTooManyRequests]: _l( - msgLingui`This feed is currently receiving high traffic and is temporarily unavailable. Please try again later.`, - ), - }[knownError]), - [_l, knownError], - ) - const [_, uri] = feedDesc.split('|') - const [ownerDid] = safeParseFeedgenUri(uri) - const removePromptControl = Prompt.usePromptControl() - const {mutateAsync: removeFeed} = useRemoveFeedMutation() - - const onViewProfile = React.useCallback(() => { - navigation.navigate('Profile', {name: ownerDid}) - }, [navigation, ownerDid]) - - const onPressRemoveFeed = React.useCallback(() => { - removePromptControl.open() - }, [removePromptControl]) - - const onRemoveFeed = React.useCallback(async () => { - try { - if (!savedFeedConfig) return - await removeFeed(savedFeedConfig) - } catch (err) { - Toast.show( - _l( - msgLingui`There was an issue removing this feed. Please check your internet connection and try again.`, - ), - 'exclamation-circle', - ) - logger.error('Failed to remove feed', {message: err}) - } - }, [removeFeed, _l, savedFeedConfig]) - - const cta = React.useMemo(() => { - switch (knownError) { - case KnownError.FeedSignedInOnly: { - return null - } - case KnownError.FeedgenDoesNotExist: - case KnownError.FeedgenMisconfigured: - case KnownError.FeedgenBadResponse: - case KnownError.FeedgenOffline: - case KnownError.FeedgenUnknown: { - return ( - - {knownError === KnownError.FeedgenDoesNotExist && - savedFeedConfig && ( -