diff options
Diffstat (limited to 'src/view/com/notifications')
-rw-r--r-- | src/view/com/notifications/Feed.tsx | 173 | ||||
-rw-r--r-- | src/view/com/notifications/FeedItem.tsx | 185 | ||||
-rw-r--r-- | src/view/com/notifications/InvitedUsers.tsx | 114 |
3 files changed, 177 insertions, 295 deletions
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 74769bc76..260c9bbd5 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -1,66 +1,76 @@ import React, {MutableRefObject} from 'react' -import {observer} from 'mobx-react-lite' import {CenteredView, FlatList} from '../util/Views' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' -import {NotificationsFeedModel} from 'state/models/feeds/notifications' import {FeedItem} from './FeedItem' import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {EmptyState} from '../util/EmptyState' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' +import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' +import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread' import {logger} from '#/logger' +import {cleanError} from '#/lib/strings/errors' +import {useModerationOpts} from '#/state/queries/preferences' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -const LOADING_SPINNER = {_reactKey: '__loading_spinner__'} +const LOADING_ITEM = {_reactKey: '__loading__'} -export const Feed = observer(function Feed({ - view, +export function Feed({ scrollElRef, onPressTryAgain, onScroll, ListHeaderComponent, }: { - view: NotificationsFeedModel scrollElRef?: MutableRefObject<FlatList<any> | null> onPressTryAgain?: () => void - onScroll?: OnScrollCb + onScroll?: OnScrollHandler ListHeaderComponent?: () => JSX.Element }) { const pal = usePalette('default') const [isPTRing, setIsPTRing] = React.useState(false) - const data = React.useMemo(() => { - let feedItems: any[] = [] - if (view.isRefreshing && !isPTRing) { - feedItems = [LOADING_SPINNER] - } - if (view.hasLoaded) { - if (view.isEmpty) { - feedItems = feedItems.concat([EMPTY_FEED_ITEM]) - } else { - feedItems = feedItems.concat(view.notifications) + + const moderationOpts = useModerationOpts() + const {checkUnread} = useUnreadNotificationsApi() + const { + data, + isFetching, + isFetched, + isError, + error, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useNotificationFeedQuery({enabled: !!moderationOpts}) + 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) } - if (view.loadMoreError) { - feedItems = (feedItems || []).concat([LOAD_MORE_ERROR_ITEM]) - } - return feedItems - }, [ - view.hasLoaded, - view.isEmpty, - view.notifications, - view.loadMoreError, - view.isRefreshing, - isPTRing, - ]) + return arr + }, [isFetched, isError, isEmpty, data]) const onRefresh = React.useCallback(async () => { try { setIsPTRing(true) - await view.refresh() + await checkUnread({invalidate: true}) } catch (err) { logger.error('Failed to refresh notifications feed', { error: err, @@ -68,21 +78,21 @@ export const Feed = observer(function Feed({ } finally { setIsPTRing(false) } - }, [view, setIsPTRing]) + }, [checkUnread, setIsPTRing]) const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + try { - await view.loadMore() + await fetchNextPage() } catch (err) { - logger.error('Failed to load more notifications', { - error: err, - }) + logger.error('Failed to load more notifications', {error: err}) } - }, [view]) + }, [isFetching, hasNextPage, isError, fetchNextPage]) const onPressRetryLoadMore = React.useCallback(() => { - view.retryLoadMore() - }, [view]) + fetchNextPage() + }, [fetchNextPage]) // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // VirtualizedList: You have a large list that is slow to update - make sure your @@ -105,77 +115,66 @@ export const Feed = observer(function Feed({ onPress={onPressRetryLoadMore} /> ) - } else if (item === LOADING_SPINNER) { - return ( - <View style={styles.loading}> - <ActivityIndicator size="small" /> - </View> - ) + } else if (item === LOADING_ITEM) { + return <NotificationFeedLoadingPlaceholder /> } - return <FeedItem item={item} /> + return <FeedItem item={item} moderationOpts={moderationOpts!} /> }, - [onPressRetryLoadMore], + [onPressRetryLoadMore, moderationOpts], ) const FeedFooter = React.useCallback( () => - view.isLoading ? ( + isFetchingNextPage ? ( <View style={styles.feedFooter}> <ActivityIndicator /> </View> ) : ( <View /> ), - [view], + [isFetchingNextPage], ) + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) return ( <View style={s.hContentRegion}> - <CenteredView> - {view.isLoading && !data.length && ( - <NotificationFeedLoadingPlaceholder /> - )} - {view.hasError && ( + {error && ( + <CenteredView> <ErrorMessage - message={view.error} + message={cleanError(error)} onPressTryAgain={onPressTryAgain} /> - )} - </CenteredView> - {data.length ? ( - <FlatList - testID="notifsFeed" - ref={scrollElRef} - data={data} - keyExtractor={item => item._reactKey} - renderItem={renderItem} - ListHeaderComponent={ListHeaderComponent} - ListFooterComponent={FeedFooter} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - onEndReached={onEndReached} - onEndReachedThreshold={0.6} - onScroll={onScroll} - scrollEventThrottle={100} - contentContainerStyle={s.contentContainer} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - ) : null} + </CenteredView> + )} + <FlatList + testID="notifsFeed" + ref={scrollElRef} + data={items} + keyExtractor={item => item._reactKey} + renderItem={renderItem} + ListHeaderComponent={ListHeaderComponent} + ListFooterComponent={FeedFooter} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + onEndReachedThreshold={0.6} + onScroll={scrollHandler} + scrollEventThrottle={1} + contentContainerStyle={s.contentContainer} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> </View> ) -}) +} const styles = StyleSheet.create({ - loading: { - paddingVertical: 20, - }, feedFooter: {paddingTop: 20}, emptyState: {paddingVertical: 40}, }) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index c38ab3fd5..aaa2ea2c6 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -1,5 +1,4 @@ -import React, {useMemo, useState, useEffect} from 'react' -import {observer} from 'mobx-react-lite' +import React, {memo, useMemo, useState, useEffect} from 'react' import { Animated, TouchableOpacity, @@ -9,6 +8,9 @@ import { } from 'react-native' import { AppBskyEmbedImages, + AppBskyFeedDefs, + AppBskyFeedPost, + ModerationOpts, ProfileModeration, moderateProfile, AppBskyEmbedRecordWithMedia, @@ -19,8 +21,7 @@ import { FontAwesomeIconStyle, Props, } from '@fortawesome/react-native-fontawesome' -import {NotificationsFeedItemModel} from 'state/models/feeds/notifications' -import {PostThreadModel} from 'state/models/content/post-thread' +import {FeedNotification} from '#/state/queries/notifications/feed' import {s, colors} from 'lib/styles' import {niceDate} from 'lib/strings/time' import {sanitizeDisplayName} from 'lib/strings/display-names' @@ -33,13 +34,14 @@ import {UserPreviewLink} from '../util/UserPreviewLink' import {ImageHorzList} from '../util/images/ImageHorzList' import {Post} from '../post/Post' import {Link, TextLink} from '../util/Link' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {formatCount} from '../util/numeric/format' import {makeProfileLink} from 'lib/routes/links' import {TimeElapsed} from '../util/TimeElapsed' import {isWeb} from 'platform/detection' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' const MAX_AUTHORS = 5 @@ -54,40 +56,34 @@ interface Author { moderation: ProfileModeration } -export const FeedItem = observer(function FeedItemImpl({ +let FeedItem = ({ item, + moderationOpts, }: { - item: NotificationsFeedItemModel -}) { - const store = useStores() + item: FeedNotification + moderationOpts: ModerationOpts +}): React.ReactNode => { const pal = usePalette('default') const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false) const itemHref = useMemo(() => { - if (item.isLike || item.isRepost) { - const urip = new AtUri(item.subjectUri) - return `/profile/${urip.host}/post/${urip.rkey}` - } else if (item.isFollow) { - return makeProfileLink(item.author) - } else if (item.isReply) { - const urip = new AtUri(item.uri) + 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.isCustomFeedLike) { - const urip = new AtUri(item.subjectUri) - return `/profile/${urip.host}/feed/${urip.rkey}` + } else if (item.type === 'feedgen-like') { + if (item.subjectUri) { + const urip = new AtUri(item.subjectUri) + return `/profile/${urip.host}/feed/${urip.rkey}` + } } return '' }, [item]) - const itemTitle = useMemo(() => { - if (item.isLike || item.isRepost) { - return 'Post' - } else if (item.isFollow) { - return item.author.handle - } else if (item.isReply) { - return 'Post' - } else if (item.isCustomFeedLike) { - return 'Custom Feed' - } - }, [item]) const onToggleAuthorsExpanded = () => { setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) @@ -96,15 +92,12 @@ export const FeedItem = observer(function FeedItemImpl({ const authors: Author[] = useMemo(() => { return [ { - href: makeProfileLink(item.author), - did: item.author.did, - handle: item.author.handle, - displayName: item.author.displayName, - avatar: item.author.avatar, - moderation: moderateProfile( - item.author, - store.preferences.moderationOpts, - ), + href: makeProfileLink(item.notification.author), + did: item.notification.author.did, + handle: item.notification.author.handle, + displayName: item.notification.author.displayName, + avatar: item.notification.author.avatar, + moderation: moderateProfile(item.notification.author, moderationOpts), }, ...(item.additional?.map(({author}) => { return { @@ -113,33 +106,35 @@ export const FeedItem = observer(function FeedItemImpl({ handle: author.handle, displayName: author.displayName, avatar: author.avatar, - moderation: moderateProfile(author, store.preferences.moderationOpts), + moderation: moderateProfile(author, moderationOpts), } }) || []), ] - }, [store, item.additional, item.author]) + }, [item, moderationOpts]) - if (item.additionalPost?.notFound) { + if (item.subjectUri && !item.subject) { // don't render anything if the target post was deleted or unfindable return <View /> } - if (item.isReply || item.isMention || item.isQuote) { - if (!item.additionalPost || item.additionalPost?.error) { - // hide errors - it doesnt help the user to show them - return <View /> + if ( + item.type === 'reply' || + item.type === 'mention' || + item.type === 'quote' + ) { + if (!item.subject) { + return null } return ( <Link - testID={`feedItem-by-${item.author.handle}`} + testID={`feedItem-by-${item.notification.author.handle}`} href={itemHref} - title={itemTitle} noFeedback accessible={false}> <Post - view={item.additionalPost} + post={item.subject} style={ - item.isRead + item.notification.isRead ? undefined : { backgroundColor: pal.colors.unreadNotifBg, @@ -154,23 +149,25 @@ export const FeedItem = observer(function FeedItemImpl({ let action = '' let icon: Props['icon'] | 'HeartIconSolid' let iconStyle: Props['style'] = [] - if (item.isLike) { + if (item.type === 'post-like') { action = 'liked your post' icon = 'HeartIconSolid' iconStyle = [ s.likeColor as FontAwesomeIconStyle, {position: 'relative', top: -4}, ] - } else if (item.isRepost) { + } else if (item.type === 'repost') { action = 'reposted your post' icon = 'retweet' iconStyle = [s.green3 as FontAwesomeIconStyle] - } else if (item.isFollow) { + } else if (item.type === 'follow') { action = 'followed you' icon = 'user-plus' iconStyle = [s.blue3 as FontAwesomeIconStyle] - } else if (item.isCustomFeedLike) { - action = `liked your custom feed '${new AtUri(item.subjectUri).rkey}'` + } else if (item.type === 'feedgen-like') { + action = `liked your custom feed${ + item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}}'` : '' + }` icon = 'HeartIconSolid' iconStyle = [ s.likeColor as FontAwesomeIconStyle, @@ -182,12 +179,12 @@ export const FeedItem = observer(function FeedItemImpl({ return ( <Link - testID={`feedItem-by-${item.author.handle}`} + testID={`feedItem-by-${item.notification.author.handle}`} style={[ styles.outer, pal.view, pal.border, - item.isRead + item.notification.isRead ? undefined : { backgroundColor: pal.colors.unreadNotifBg, @@ -195,9 +192,11 @@ export const FeedItem = observer(function FeedItemImpl({ }, ]} href={itemHref} - title={itemTitle} noFeedback - accessible={(item.isLike && authors.length === 1) || item.isRepost}> + accessible={ + (item.type === 'post-like' && authors.length === 1) || + item.type === 'repost' + }> <View style={styles.layoutIcon}> {/* TODO: Prevent conditional rendering and move toward composable notifications for clearer accessibility labeling */} @@ -232,7 +231,10 @@ export const FeedItem = observer(function FeedItemImpl({ /> {authors.length > 1 ? ( <> - <Text style={[pal.text]}> and </Text> + <Text style={[pal.text, s.mr5, s.ml5]}> + {' '} + <Trans>and</Trans>{' '} + </Text> <Text style={[pal.text, s.bold]}> {formatCount(authors.length - 1)}{' '} {pluralize(authors.length - 1, 'other')} @@ -240,24 +242,26 @@ export const FeedItem = observer(function FeedItemImpl({ </> ) : undefined} <Text style={[pal.text]}> {action}</Text> - <TimeElapsed timestamp={item.indexedAt}> + <TimeElapsed timestamp={item.notification.indexedAt}> {({timeElapsed}) => ( <Text style={[pal.textLight, styles.pointer]} - title={niceDate(item.indexedAt)}> + title={niceDate(item.notification.indexedAt)}> {' ' + timeElapsed} </Text> )} </TimeElapsed> </Text> </ExpandListPressable> - {item.isLike || item.isRepost || item.isQuote ? ( - <AdditionalPostText additionalPost={item.additionalPost} /> + {item.type === 'post-like' || item.type === 'repost' ? ( + <AdditionalPostText post={item.subject} /> ) : null} </View> </Link> ) -}) +} +FeedItem = memo(FeedItem) +export {FeedItem} function ExpandListPressable({ hasMultipleAuthors, @@ -292,6 +296,8 @@ function CondensedAuthorsList({ onToggleAuthorsExpanded: () => void }) { const pal = usePalette('default') + const {_} = useLingui() + if (!visible) { return ( <View style={styles.avis}> @@ -299,7 +305,7 @@ function CondensedAuthorsList({ style={styles.expandedAuthorsCloseBtn} onPress={onToggleAuthorsExpanded} accessibilityRole="button" - accessibilityLabel="Hide user list" + accessibilityLabel={_(msg`Hide user list`)} accessibilityHint="Collapses list of users for a given notification"> <FontAwesomeIcon icon="angle-up" @@ -307,7 +313,7 @@ function CondensedAuthorsList({ style={[styles.expandedAuthorsCloseBtnIcon, pal.text]} /> <Text type="sm-medium" style={pal.text}> - Hide + <Trans>Hide</Trans> </Text> </TouchableOpacity> </View> @@ -328,7 +334,7 @@ function CondensedAuthorsList({ } return ( <TouchableOpacity - accessibilityLabel="Show users" + accessibilityLabel={_(msg`Show users`)} accessibilityHint="Opens an expanded list of users in this notification" onPress={onToggleAuthorsExpanded}> <View style={styles.avis}> @@ -417,34 +423,25 @@ function ExpandedAuthorsList({ ) } -function AdditionalPostText({ - additionalPost, -}: { - additionalPost?: PostThreadModel -}) { +function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { const pal = usePalette('default') - if ( - !additionalPost || - !additionalPost.thread?.postRecord || - additionalPost.error - ) { - return <View /> + if (post && AppBskyFeedPost.isRecord(post?.record)) { + const text = post.record.text + const images = AppBskyEmbedImages.isView(post.embed) + ? post.embed.images + : AppBskyEmbedRecordWithMedia.isView(post.embed) && + AppBskyEmbedImages.isView(post.embed.media) + ? post.embed.media.images + : undefined + return ( + <> + {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} + {images && images?.length > 0 && ( + <ImageHorzList images={images} style={styles.additionalPostImages} /> + )} + </> + ) } - const text = additionalPost.thread?.postRecord.text - const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed) - ? additionalPost.thread.post.embed.images - : AppBskyEmbedRecordWithMedia.isView(additionalPost.thread.post.embed) && - AppBskyEmbedImages.isView(additionalPost.thread.post.embed.media) - ? additionalPost.thread.post.embed.media.images - : undefined - return ( - <> - {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} - {images && images?.length > 0 && ( - <ImageHorzList images={images} style={styles.additionalPostImages} /> - )} - </> - ) } const styles = StyleSheet.create({ diff --git a/src/view/com/notifications/InvitedUsers.tsx b/src/view/com/notifications/InvitedUsers.tsx deleted file mode 100644 index aaf358b87..000000000 --- a/src/view/com/notifications/InvitedUsers.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from 'react' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {AppBskyActorDefs} from '@atproto/api' -import {UserAvatar} from '../util/UserAvatar' -import {Text} from '../util/text/Text' -import {Link, TextLink} from '../util/Link' -import {Button} from '../util/forms/Button' -import {FollowButton} from '../profile/FollowButton' -import {CenteredView} from '../util/Views.web' -import {useStores} from 'state/index' -import {usePalette} from 'lib/hooks/usePalette' -import {s} from 'lib/styles' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {makeProfileLink} from 'lib/routes/links' - -export const InvitedUsers = observer(function InvitedUsersImpl() { - const store = useStores() - return ( - <CenteredView> - {store.invitedUsers.profiles.map(profile => ( - <InvitedUser key={profile.did} profile={profile} /> - ))} - </CenteredView> - ) -}) - -function InvitedUser({ - profile, -}: { - profile: AppBskyActorDefs.ProfileViewDetailed -}) { - const pal = usePalette('default') - const store = useStores() - - const onPressDismiss = React.useCallback(() => { - store.invitedUsers.markSeen(profile.did) - }, [store, profile]) - - return ( - <View - testID="invitedUser" - style={[ - styles.layout, - { - backgroundColor: pal.colors.unreadNotifBg, - borderColor: pal.colors.unreadNotifBorder, - }, - ]}> - <View style={styles.layoutIcon}> - <FontAwesomeIcon - icon="user-plus" - size={24} - style={[styles.icon, s.blue3 as FontAwesomeIconStyle]} - /> - </View> - <View style={s.flex1}> - <Link href={makeProfileLink(profile)}> - <UserAvatar avatar={profile.avatar} size={35} /> - </Link> - <Text style={[styles.desc, pal.text]}> - <TextLink - type="md-bold" - style={pal.text} - href={makeProfileLink(profile)} - text={sanitizeDisplayName(profile.displayName || profile.handle)} - />{' '} - joined using your invite code! - </Text> - <View style={styles.btns}> - <FollowButton - unfollowedType="primary" - followedType="primary-light" - profile={profile} - /> - <Button - testID="dismissBtn" - type="primary-light" - label="Dismiss" - onPress={onPressDismiss} - /> - </View> - </View> - </View> - ) -} - -const styles = StyleSheet.create({ - layout: { - flexDirection: 'row', - borderTopWidth: 1, - padding: 10, - }, - layoutIcon: { - width: 70, - alignItems: 'flex-end', - paddingTop: 2, - }, - icon: { - marginRight: 10, - marginTop: 4, - }, - desc: { - paddingVertical: 6, - }, - btns: { - flexDirection: 'row', - gap: 10, - }, -}) |