Refactor notifications to use react-query (#1878)

* Move broadcast channel to lib

* Refactor view/com/post/Post and remove temporary 2 components

* Add useModerationOpts hook

* Refactor notifications to use react-query

* Fix: only trigger updates in useModerationOpts when the values have changed

* Implement unread notification tracking

* Add moderation filtering to notifications

* Handle native/push notifications

* Remove dead code

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Paul Frazee 2023-11-12 18:13:11 -08:00 committed by GitHub
parent c584a3378d
commit b445c15cc9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 941 additions and 1739 deletions

View file

@ -1,8 +1,6 @@
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'
@ -12,20 +10,22 @@ 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?: OnScrollHandler
@ -33,35 +33,54 @@ export const Feed = observer(function Feed({
}) {
const pal = usePalette('default')
const [isPTRing, setIsPTRing] = React.useState(false)
const data = React.useMemo(() => {
let feedItems: any[] = []
if (view.isRefreshing && !isPTRing) {
feedItems = [LOADING_SPINNER]
const moderationOpts = useModerationOpts()
const {markAllRead} = useUnreadNotificationsApi()
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isError,
error,
refetch,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useNotificationFeedQuery({enabled: !!moderationOpts})
const isEmpty = !isFetching && !data?.pages[0]?.items.length
const firstItem = data?.pages[0]?.items[0]
// mark all read on fresh data
React.useEffect(() => {
if (firstItem) {
markAllRead()
}
if (view.hasLoaded) {
if (view.isEmpty) {
feedItems = feedItems.concat([EMPTY_FEED_ITEM])
} else {
feedItems = feedItems.concat(view.notifications)
}, [firstItem, markAllRead])
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 refetch()
} catch (err) {
logger.error('Failed to refresh notifications feed', {
error: err,
@ -69,21 +88,21 @@ export const Feed = observer(function Feed({
} finally {
setIsPTRing(false)
}
}, [view, setIsPTRing])
}, [refetch, 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
@ -106,78 +125,72 @@ 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}
dataUpdatedAt={dataUpdatedAt}
moderationOpts={moderationOpts!}
/>
)
},
[onPressRetryLoadMore],
[onPressRetryLoadMore, dataUpdatedAt, 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={scrollHandler}
scrollEventThrottle={1}
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},
})

View file

@ -1,5 +1,4 @@
import React, {useMemo, useState, useEffect} from 'react'
import {observer} from 'mobx-react-lite'
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,7 +34,6 @@ 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'
@ -56,40 +56,36 @@ interface Author {
moderation: ProfileModeration
}
export const FeedItem = observer(function FeedItemImpl({
export function FeedItem({
item,
dataUpdatedAt,
moderationOpts,
}: {
item: NotificationsFeedItemModel
item: FeedNotification
dataUpdatedAt: number
moderationOpts: ModerationOpts
}) {
const store = useStores()
const pal = usePalette('default')
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
const itemHref = useMemo(() => {
if (item.isLike || item.isRepost) {
const urip = new AtUri(item.subjectUri)
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.isFollow) {
return makeProfileLink(item.author)
} else if (item.isReply) {
const urip = new AtUri(item.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)
@ -98,15 +94,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 {
@ -115,33 +108,36 @@ 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}
dataUpdatedAt={dataUpdatedAt}
style={
item.isRead
item.notification.isRead
? undefined
: {
backgroundColor: pal.colors.unreadNotifBg,
@ -156,23 +152,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,
@ -184,12 +182,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,
@ -197,9 +195,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 */}
@ -244,24 +244,24 @@ 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>
)
})
}
function ExpandListPressable({
hasMultipleAuthors,
@ -423,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({