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({

View file

@ -23,8 +23,8 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
import {useStores} from 'state/index'
import {PostMeta} from '../util/PostMeta'
import {PostEmbeds} from '../util/post-embeds'
import {PostCtrls} from '../util/post-ctrls/PostCtrls2'
import {PostDropdownBtn} from '../util/forms/PostDropdownBtn2'
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
import {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider'
import {PostAlerts} from '../util/moderation/PostAlerts'

View file

@ -1,19 +1,14 @@
import React, {useState} from 'react'
import React, {useState, useMemo} from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {
ActivityIndicator,
Linking,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import {AppBskyFeedPost as FeedPost} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import Clipboard from '@react-native-clipboard/clipboard'
import {AtUri} from '@atproto/api'
AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
moderatePost,
PostModeration,
RichText as RichTextAPI,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {PostThreadModel} from 'state/models/content/post-thread'
import {PostThreadItemModel} from 'state/models/content/post-thread-item'
import {Link, TextLink} from '../util/Link'
import {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta'
@ -23,174 +18,111 @@ import {ContentHider} from '../util/moderation/ContentHider'
import {PostAlerts} from '../util/moderation/PostAlerts'
import {Text} from '../util/text/Text'
import {RichText} from '../util/text/RichText'
import * as Toast from '../util/Toast'
import {PreviewableUserAvatar} from '../util/UserAvatar'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {getTranslatorLink} from '../../../locale/helpers'
import {makeProfileLink} from 'lib/routes/links'
import {MAX_POST_LINES} from 'lib/constants'
import {countLines} from 'lib/strings/helpers'
import {logger} from '#/logger'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {useModerationOpts} from '#/state/queries/preferences'
import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
export const Post = observer(function PostImpl({
view,
export function Post({
post,
dataUpdatedAt,
showReplyLine,
hideError,
style,
}: {
view: PostThreadModel
post: AppBskyFeedDefs.PostView
dataUpdatedAt: number
showReplyLine?: boolean
hideError?: boolean
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
const [deleted, setDeleted] = useState(false)
// deleted
// =
if (deleted) {
return <View />
}
// loading
// =
if (!view.hasContent && view.isLoading) {
return (
<View style={pal.view}>
<ActivityIndicator />
</View>
)
}
// error
// =
if (view.hasError || !view.thread || !view.thread?.postRecord) {
if (hideError) {
return <View />
}
return (
<View style={pal.view}>
<Text>{view.error || 'Thread not found'}</Text>
</View>
)
}
// loaded
// =
return (
<PostLoaded
item={view.thread}
record={view.thread.postRecord}
setDeleted={setDeleted}
showReplyLine={showReplyLine}
style={style}
/>
const moderationOpts = useModerationOpts()
const record = useMemo<AppBskyFeedPost.Record | undefined>(
() =>
AppBskyFeedPost.isRecord(post.record) &&
AppBskyFeedPost.validateRecord(post.record).success
? post.record
: undefined,
[post],
)
})
const postShadowed = usePostShadow(post, dataUpdatedAt)
const richText = useMemo(
() =>
record
? new RichTextAPI({
text: record.text,
facets: record.facets,
})
: undefined,
[record],
)
const moderation = useMemo(
() => (moderationOpts ? moderatePost(post, moderationOpts) : undefined),
[moderationOpts, post],
)
if (postShadowed === POST_TOMBSTONE) {
return null
}
if (record && richText && moderation) {
return (
<PostInner
post={postShadowed}
record={record}
richText={richText}
moderation={moderation}
showReplyLine={showReplyLine}
style={style}
/>
)
}
return null
}
const PostLoaded = observer(function PostLoadedImpl({
item,
function PostInner({
post,
record,
setDeleted,
richText,
moderation,
showReplyLine,
style,
}: {
item: PostThreadItemModel
record: FeedPost.Record
setDeleted: (v: boolean) => void
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
richText: RichTextAPI
moderation: PostModeration
showReplyLine?: boolean
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
const store = useStores()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const langPrefs = useLanguagePrefs()
const [limitLines, setLimitLines] = React.useState(
countLines(item.richText?.text) >= MAX_POST_LINES,
const [limitLines, setLimitLines] = useState(
countLines(richText?.text) >= MAX_POST_LINES,
)
const itemUri = item.post.uri
const itemCid = item.post.cid
const itemUrip = new AtUri(item.post.uri)
const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey)
const itemTitle = `Post by ${item.post.author.handle}`
const itemUrip = new AtUri(post.uri)
const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
let replyAuthorDid = ''
if (record.reply) {
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
replyAuthorDid = urip.hostname
}
const translatorUrl = getTranslatorLink(
record?.text || '',
langPrefs.primaryLanguage,
)
const onPressReply = React.useCallback(() => {
store.shell.openComposer({
replyTo: {
uri: item.post.uri,
cid: item.post.cid,
text: record.text as string,
uri: post.uri,
cid: post.cid,
text: record.text,
author: {
handle: item.post.author.handle,
displayName: item.post.author.displayName,
avatar: item.post.author.avatar,
handle: post.author.handle,
displayName: post.author.displayName,
avatar: post.author.avatar,
},
},
})
}, [store, item, record])
const onPressToggleRepost = React.useCallback(() => {
return item
.toggleRepost()
.catch(e => logger.error('Failed to toggle repost', {error: e}))
}, [item])
const onPressToggleLike = React.useCallback(() => {
return item
.toggleLike()
.catch(e => logger.error('Failed to toggle like', {error: e}))
}, [item])
const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record.text)
Toast.show('Copied to clipboard')
}, [record])
const onOpenTranslate = React.useCallback(() => {
Linking.openURL(translatorUrl)
}, [translatorUrl])
const onToggleThreadMute = React.useCallback(() => {
try {
const muted = toggleThreadMute(item.data.rootUri)
if (muted) {
Toast.show('You will no longer receive notifications for this thread')
} else {
Toast.show('You will now receive notifications for this thread')
}
} catch (e) {
logger.error('Failed to toggle thread mute', {error: e})
}
}, [item, toggleThreadMute])
const onDeletePost = React.useCallback(() => {
item.delete().then(
() => {
setDeleted(true)
Toast.show('Post deleted')
},
e => {
logger.error('Failed to delete post', {error: e})
Toast.show('Failed to delete post, please try again')
},
)
}, [item, setDeleted])
}, [store, post, record])
const onPressShowMore = React.useCallback(() => {
setLimitLines(false)
@ -203,17 +135,17 @@ const PostLoaded = observer(function PostLoadedImpl({
<View style={styles.layoutAvi}>
<PreviewableUserAvatar
size={52}
did={item.post.author.did}
handle={item.post.author.handle}
avatar={item.post.author.avatar}
moderation={item.moderation.avatar}
did={post.author.did}
handle={post.author.handle}
avatar={post.author.avatar}
moderation={moderation.avatar}
/>
</View>
<View style={styles.layoutContent}>
<PostMeta
author={item.post.author}
authorHasWarning={!!item.post.author.labels?.length}
timestamp={item.post.indexedAt}
author={post.author}
authorHasWarning={!!post.author.labels?.length}
timestamp={post.indexedAt}
postHref={itemHref}
/>
{replyAuthorDid !== '' && (
@ -239,19 +171,16 @@ const PostLoaded = observer(function PostLoadedImpl({
</View>
)}
<ContentHider
moderation={item.moderation.content}
moderation={moderation.content}
style={styles.contentHider}
childContainerStyle={styles.contentHiderChild}>
<PostAlerts
moderation={item.moderation.content}
style={styles.alert}
/>
{item.richText?.text ? (
<PostAlerts moderation={moderation.content} style={styles.alert} />
{richText.text ? (
<View style={styles.postTextContainer}>
<RichText
testID="postText"
type="post-text"
richText={item.richText}
richText={richText}
lineHeight={1.3}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
style={s.flex1}
@ -266,45 +195,20 @@ const PostLoaded = observer(function PostLoadedImpl({
href="#"
/>
) : undefined}
{item.post.embed ? (
{post.embed ? (
<ContentHider
moderation={item.moderation.embed}
moderation={moderation.embed}
style={styles.contentHider}>
<PostEmbeds
embed={item.post.embed}
moderation={item.moderation.embed}
/>
<PostEmbeds embed={post.embed} moderation={moderation.embed} />
</ContentHider>
) : null}
</ContentHider>
<PostCtrls
itemUri={itemUri}
itemCid={itemCid}
itemHref={itemHref}
itemTitle={itemTitle}
author={item.post.author}
indexedAt={item.post.indexedAt}
text={item.richText?.text || record.text}
isAuthor={item.post.author.did === store.me.did}
replyCount={item.post.replyCount}
repostCount={item.post.repostCount}
likeCount={item.post.likeCount}
isReposted={!!item.post.viewer?.repost}
isLiked={!!item.post.viewer?.like}
isThreadMuted={mutedThreads.includes(item.data.rootUri)}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost}
/>
<PostCtrls post={post} record={record} onPressReply={onPressReply} />
</View>
</View>
</Link>
)
})
}
const styles = StyleSheet.create({
outer: {

View file

@ -68,7 +68,7 @@ export function Feed({
const pal = usePalette('default')
const theme = useTheme()
const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false)
const [isPTRing, setIsPTRing] = React.useState(false)
const checkForNewRef = React.useRef<(() => void) | null>(null)
const opts = React.useMemo(() => ({enabled}), [enabled])
@ -137,15 +137,15 @@ export function Feed({
const onRefresh = React.useCallback(async () => {
track('Feed:onRefresh')
setIsRefreshing(true)
setIsPTRing(true)
try {
await refetch()
onHasNew?.(false)
} catch (err) {
logger.error('Failed to refresh posts feed', {error: err})
}
setIsRefreshing(false)
}, [refetch, track, setIsRefreshing, onHasNew])
setIsPTRing(false)
}, [refetch, track, setIsPTRing, onHasNew])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
@ -233,7 +233,7 @@ export function Feed({
ListHeaderComponent={ListHeaderComponent}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
refreshing={isPTRing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}

View file

@ -16,7 +16,7 @@ import {Link, TextLinkOnWebOnly, TextLink} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta'
import {PostCtrls} from '../util/post-ctrls/PostCtrls2'
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
import {PostEmbeds} from '../util/post-embeds'
import {ContentHider} from '../util/moderation/ContentHider'
import {PostAlerts} from '../util/moderation/PostAlerts'

View file

@ -1,6 +1,8 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {Linking, StyleProp, View, ViewStyle} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
import {toShareUrl} from 'lib/strings/url-helpers'
import {useTheme} from 'lib/ThemeContext'
import {shareUrl} from 'lib/sharing'
@ -8,41 +10,83 @@ import {
NativeDropdown,
DropdownItem as NativeDropdownItem,
} from './NativeDropdown'
import * as Toast from '../Toast'
import {EventStopper} from '../EventStopper'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useModalControls} from '#/state/modals'
import {makeProfileLink} from '#/lib/routes/links'
import {getTranslatorLink} from '#/locale/helpers'
import {useStores} from '#/state'
import {usePostDeleteMutation} from '#/state/queries/post'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {logger} from '#/logger'
export function PostDropdownBtn({
testID,
itemUri,
itemCid,
itemHref,
isAuthor,
isThreadMuted,
onCopyPostText,
onOpenTranslate,
onToggleThreadMute,
onDeletePost,
post,
record,
style,
}: {
testID: string
itemUri: string
itemCid: string
itemHref: string
itemTitle: string
isAuthor: boolean
isThreadMuted: boolean
onCopyPostText: () => void
onOpenTranslate: () => void
onToggleThreadMute: () => void
onDeletePost: () => void
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
}) {
const store = useStores()
const theme = useTheme()
const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl
const {openModal} = useModalControls()
const langPrefs = useLanguagePrefs()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const postDeleteMutation = usePostDeleteMutation()
const rootUri = record.reply?.root?.uri || post.uri
const isThreadMuted = mutedThreads.includes(rootUri)
const isAuthor = post.author.did === store.me.did
const href = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const translatorUrl = getTranslatorLink(
record.text,
langPrefs.primaryLanguage,
)
const onDeletePost = React.useCallback(() => {
postDeleteMutation.mutateAsync({uri: post.uri}).then(
() => {
Toast.show('Post deleted')
},
e => {
logger.error('Failed to delete post', {error: e})
Toast.show('Failed to delete post, please try again')
},
)
}, [post, postDeleteMutation])
const onToggleThreadMute = React.useCallback(() => {
try {
const muted = toggleThreadMute(rootUri)
if (muted) {
Toast.show('You will no longer receive notifications for this thread')
} else {
Toast.show('You will now receive notifications for this thread')
}
} catch (e) {
logger.error('Failed to toggle thread mute', {error: e})
}
}, [rootUri, toggleThreadMute])
const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record?.text || '')
Toast.show('Copied to clipboard')
}, [record])
const onOpenTranslate = React.useCallback(() => {
Linking.openURL(translatorUrl)
}, [translatorUrl])
const dropdownItems: NativeDropdownItem[] = [
{
@ -76,7 +120,7 @@ export function PostDropdownBtn({
{
label: 'Share',
onPress() {
const url = toShareUrl(itemHref)
const url = toShareUrl(href)
shareUrl(url)
},
testID: 'postDropdownShareBtn',
@ -113,8 +157,8 @@ export function PostDropdownBtn({
onPress() {
openModal({
name: 'report',
uri: itemUri,
cid: itemCid,
uri: post.uri,
cid: post.cid,
})
},
testID: 'postDropdownReportBtn',
@ -155,7 +199,7 @@ export function PostDropdownBtn({
<NativeDropdown
testID={testID}
items={dropdownItems}
accessibilityLabel={_(msg`More post options`)}
accessibilityLabel="More post options"
accessibilityHint="">
<View style={style}>
<FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />

View file

@ -1,210 +0,0 @@
import React from 'react'
import {Linking, StyleProp, View, ViewStyle} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
import {toShareUrl} from 'lib/strings/url-helpers'
import {useTheme} from 'lib/ThemeContext'
import {shareUrl} from 'lib/sharing'
import {
NativeDropdown,
DropdownItem as NativeDropdownItem,
} from './NativeDropdown'
import * as Toast from '../Toast'
import {EventStopper} from '../EventStopper'
import {useModalControls} from '#/state/modals'
import {makeProfileLink} from '#/lib/routes/links'
import {getTranslatorLink} from '#/locale/helpers'
import {useStores} from '#/state'
import {usePostDeleteMutation} from '#/state/queries/post'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {logger} from '#/logger'
export function PostDropdownBtn({
testID,
post,
record,
style,
}: {
testID: string
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
}) {
const store = useStores()
const theme = useTheme()
const defaultCtrlColor = theme.palette.default.postCtrl
const {openModal} = useModalControls()
const langPrefs = useLanguagePrefs()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const postDeleteMutation = usePostDeleteMutation()
const rootUri = record.reply?.root?.uri || post.uri
const isThreadMuted = mutedThreads.includes(rootUri)
const isAuthor = post.author.did === store.me.did
const href = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const translatorUrl = getTranslatorLink(
record.text,
langPrefs.primaryLanguage,
)
const onDeletePost = React.useCallback(() => {
postDeleteMutation.mutateAsync({uri: post.uri}).then(
() => {
Toast.show('Post deleted')
},
e => {
logger.error('Failed to delete post', {error: e})
Toast.show('Failed to delete post, please try again')
},
)
}, [post, postDeleteMutation])
const onToggleThreadMute = React.useCallback(() => {
try {
const muted = toggleThreadMute(rootUri)
if (muted) {
Toast.show('You will no longer receive notifications for this thread')
} else {
Toast.show('You will now receive notifications for this thread')
}
} catch (e) {
logger.error('Failed to toggle thread mute', {error: e})
}
}, [rootUri, toggleThreadMute])
const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record?.text || '')
Toast.show('Copied to clipboard')
}, [record])
const onOpenTranslate = React.useCallback(() => {
Linking.openURL(translatorUrl)
}, [translatorUrl])
const dropdownItems: NativeDropdownItem[] = [
{
label: 'Translate',
onPress() {
onOpenTranslate()
},
testID: 'postDropdownTranslateBtn',
icon: {
ios: {
name: 'character.book.closed',
},
android: 'ic_menu_sort_alphabetically',
web: 'language',
},
},
{
label: 'Copy post text',
onPress() {
onCopyPostText()
},
testID: 'postDropdownCopyTextBtn',
icon: {
ios: {
name: 'doc.on.doc',
},
android: 'ic_menu_edit',
web: ['far', 'paste'],
},
},
{
label: 'Share',
onPress() {
const url = toShareUrl(href)
shareUrl(url)
},
testID: 'postDropdownShareBtn',
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: 'ic_menu_share',
web: 'share',
},
},
{
label: 'separator',
},
{
label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
onPress() {
onToggleThreadMute()
},
testID: 'postDropdownMuteThreadBtn',
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_lock_silent_mode',
web: 'comment-slash',
},
},
{
label: 'separator',
},
!isAuthor && {
label: 'Report post',
onPress() {
openModal({
name: 'report',
uri: post.uri,
cid: post.cid,
})
},
testID: 'postDropdownReportBtn',
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
},
isAuthor && {
label: 'separator',
},
isAuthor && {
label: 'Delete post',
onPress() {
openModal({
name: 'confirm',
title: 'Delete this post?',
message: 'Are you sure? This can not be undone.',
onPressConfirm: onDeletePost,
})
},
testID: 'postDropdownDeleteBtn',
icon: {
ios: {
name: 'trash',
},
android: 'ic_menu_delete',
web: ['far', 'trash-can'],
},
},
].filter(Boolean) as NativeDropdownItem[]
return (
<EventStopper>
<NativeDropdown
testID={testID}
items={dropdownItems}
accessibilityLabel="More post options"
accessibilityHint="">
<View style={style}>
<FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
</View>
</NativeDropdown>
</EventStopper>
)
}

View file

@ -6,6 +6,7 @@ import {
View,
ViewStyle,
} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
import {Text} from '../text/Text'
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
@ -17,160 +18,155 @@ import {RepostButton} from './RepostButton'
import {Haptics} from 'lib/haptics'
import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
import {useModalControls} from '#/state/modals'
import {
usePostLikeMutation,
usePostUnlikeMutation,
usePostRepostMutation,
usePostUnrepostMutation,
} from '#/state/queries/post'
interface PostCtrlsOpts {
itemUri: string
itemCid: string
itemHref: string
itemTitle: string
isAuthor: boolean
author: {
did: string
handle: string
displayName?: string | undefined
avatar?: string | undefined
}
text: string
indexedAt: string
export function PostCtrls({
big,
post,
record,
style,
onPressReply,
}: {
big?: boolean
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
replyCount?: number
repostCount?: number
likeCount?: number
isReposted: boolean
isLiked: boolean
isThreadMuted: boolean
onPressReply: () => void
onPressToggleRepost: () => Promise<void>
onPressToggleLike: () => Promise<void>
onCopyPostText: () => void
onOpenTranslate: () => void
onToggleThreadMute: () => void
onDeletePost: () => void
}
export function PostCtrls(opts: PostCtrlsOpts) {
}) {
const store = useStores()
const theme = useTheme()
const {closeModal} = useModalControls()
const postLikeMutation = usePostLikeMutation()
const postUnlikeMutation = usePostUnlikeMutation()
const postRepostMutation = usePostRepostMutation()
const postUnrepostMutation = usePostUnrepostMutation()
const defaultCtrlColor = React.useMemo(
() => ({
color: theme.palette.default.postCtrl,
}),
[theme],
) as StyleProp<ViewStyle>
const onPressToggleLike = React.useCallback(async () => {
if (!post.viewer?.like) {
Haptics.default()
postLikeMutation.mutate({
uri: post.uri,
cid: post.cid,
likeCount: post.likeCount || 0,
})
} else {
postUnlikeMutation.mutate({
postUri: post.uri,
likeUri: post.viewer.like,
likeCount: post.likeCount || 0,
})
}
}, [post, postLikeMutation, postUnlikeMutation])
const onRepost = useCallback(() => {
closeModal()
if (!opts.isReposted) {
if (!post.viewer?.repost) {
Haptics.default()
opts.onPressToggleRepost().catch(_e => undefined)
postRepostMutation.mutate({
uri: post.uri,
cid: post.cid,
repostCount: post.repostCount || 0,
})
} else {
opts.onPressToggleRepost().catch(_e => undefined)
postUnrepostMutation.mutate({
postUri: post.uri,
repostUri: post.viewer.repost,
repostCount: post.repostCount || 0,
})
}
}, [opts, closeModal])
}, [post, closeModal, postRepostMutation, postUnrepostMutation])
const onQuote = useCallback(() => {
closeModal()
store.shell.openComposer({
quote: {
uri: opts.itemUri,
cid: opts.itemCid,
text: opts.text,
author: opts.author,
indexedAt: opts.indexedAt,
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
indexedAt: post.indexedAt,
},
})
Haptics.default()
}, [
opts.author,
opts.indexedAt,
opts.itemCid,
opts.itemUri,
opts.text,
store.shell,
closeModal,
])
const onPressToggleLikeWrapper = async () => {
if (!opts.isLiked) {
Haptics.default()
await opts.onPressToggleLike().catch(_e => undefined)
} else {
await opts.onPressToggleLike().catch(_e => undefined)
}
}
}, [post, record, store.shell, closeModal])
return (
<View style={[styles.ctrls, opts.style]}>
<View style={[styles.ctrls, style]}>
<TouchableOpacity
testID="replyBtn"
style={[styles.ctrl, !opts.big && styles.ctrlPad, {paddingLeft: 0}]}
onPress={opts.onPressReply}
style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
onPress={onPressReply}
accessibilityRole="button"
accessibilityLabel={`Reply (${opts.replyCount} ${
opts.replyCount === 1 ? 'reply' : 'replies'
accessibilityLabel={`Reply (${post.replyCount} ${
post.replyCount === 1 ? 'reply' : 'replies'
})`}
accessibilityHint=""
hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}>
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
<CommentBottomArrow
style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
style={[defaultCtrlColor, big ? s.mt2 : styles.mt1]}
strokeWidth={3}
size={opts.big ? 20 : 15}
size={big ? 20 : 15}
/>
{typeof opts.replyCount !== 'undefined' ? (
{typeof post.replyCount !== 'undefined' ? (
<Text style={[defaultCtrlColor, s.ml5, s.f15]}>
{opts.replyCount}
{post.replyCount}
</Text>
) : undefined}
</TouchableOpacity>
<RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} />
<RepostButton
big={big}
isReposted={!!post.viewer?.repost}
repostCount={post.repostCount}
onRepost={onRepost}
onQuote={onQuote}
/>
<TouchableOpacity
testID="likeBtn"
style={[styles.ctrl, !opts.big && styles.ctrlPad]}
onPress={onPressToggleLikeWrapper}
style={[styles.ctrl, !big && styles.ctrlPad]}
onPress={onPressToggleLike}
accessibilityRole="button"
accessibilityLabel={`${opts.isLiked ? 'Unlike' : 'Like'} (${
opts.likeCount
} ${pluralize(opts.likeCount || 0, 'like')})`}
accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
post.likeCount
} ${pluralize(post.likeCount || 0, 'like')})`}
accessibilityHint=""
hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}>
{opts.isLiked ? (
<HeartIconSolid
style={styles.ctrlIconLiked}
size={opts.big ? 22 : 16}
/>
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
{post.viewer?.like ? (
<HeartIconSolid style={styles.ctrlIconLiked} size={big ? 22 : 16} />
) : (
<HeartIcon
style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
style={[defaultCtrlColor, big ? styles.mt1 : undefined]}
strokeWidth={3}
size={opts.big ? 20 : 16}
size={big ? 20 : 16}
/>
)}
{typeof opts.likeCount !== 'undefined' ? (
{typeof post.likeCount !== 'undefined' ? (
<Text
testID="likeCount"
style={
opts.isLiked
post.viewer?.like
? [s.bold, s.red3, s.f15, s.ml5]
: [defaultCtrlColor, s.f15, s.ml5]
}>
{opts.likeCount}
{post.likeCount}
</Text>
) : undefined}
</TouchableOpacity>
{opts.big ? undefined : (
{big ? undefined : (
<PostDropdownBtn
testID="postDropdownBtn"
itemUri={opts.itemUri}
itemCid={opts.itemCid}
itemHref={opts.itemHref}
itemTitle={opts.itemTitle}
isAuthor={opts.isAuthor}
isThreadMuted={opts.isThreadMuted}
onCopyPostText={opts.onCopyPostText}
onOpenTranslate={opts.onOpenTranslate}
onToggleThreadMute={opts.onToggleThreadMute}
onDeletePost={opts.onDeletePost}
post={post}
record={record}
style={styles.ctrlPad}
/>
)}

View file

@ -1,200 +0,0 @@
import React, {useCallback} from 'react'
import {
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
import {Text} from '../text/Text'
import {PostDropdownBtn} from '../forms/PostDropdownBtn2'
import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
import {s, colors} from 'lib/styles'
import {pluralize} from 'lib/strings/helpers'
import {useTheme} from 'lib/ThemeContext'
import {useStores} from 'state/index'
import {RepostButton} from './RepostButton'
import {Haptics} from 'lib/haptics'
import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
import {useModalControls} from '#/state/modals'
import {
usePostLikeMutation,
usePostUnlikeMutation,
usePostRepostMutation,
usePostUnrepostMutation,
} from '#/state/queries/post'
export function PostCtrls({
big,
post,
record,
style,
onPressReply,
}: {
big?: boolean
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
onPressReply: () => void
}) {
const store = useStores()
const theme = useTheme()
const {closeModal} = useModalControls()
const postLikeMutation = usePostLikeMutation()
const postUnlikeMutation = usePostUnlikeMutation()
const postRepostMutation = usePostRepostMutation()
const postUnrepostMutation = usePostUnrepostMutation()
const defaultCtrlColor = React.useMemo(
() => ({
color: theme.palette.default.postCtrl,
}),
[theme],
) as StyleProp<ViewStyle>
const onPressToggleLike = React.useCallback(async () => {
if (!post.viewer?.like) {
Haptics.default()
postLikeMutation.mutate({
uri: post.uri,
cid: post.cid,
likeCount: post.likeCount || 0,
})
} else {
postUnlikeMutation.mutate({
postUri: post.uri,
likeUri: post.viewer.like,
likeCount: post.likeCount || 0,
})
}
}, [post, postLikeMutation, postUnlikeMutation])
const onRepost = useCallback(() => {
closeModal()
if (!post.viewer?.repost) {
Haptics.default()
postRepostMutation.mutate({
uri: post.uri,
cid: post.cid,
repostCount: post.repostCount || 0,
})
} else {
postUnrepostMutation.mutate({
postUri: post.uri,
repostUri: post.viewer.repost,
repostCount: post.repostCount || 0,
})
}
}, [post, closeModal, postRepostMutation, postUnrepostMutation])
const onQuote = useCallback(() => {
closeModal()
store.shell.openComposer({
quote: {
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
indexedAt: post.indexedAt,
},
})
Haptics.default()
}, [post, record, store.shell, closeModal])
return (
<View style={[styles.ctrls, style]}>
<TouchableOpacity
testID="replyBtn"
style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
onPress={onPressReply}
accessibilityRole="button"
accessibilityLabel={`Reply (${post.replyCount} ${
post.replyCount === 1 ? 'reply' : 'replies'
})`}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
<CommentBottomArrow
style={[defaultCtrlColor, big ? s.mt2 : styles.mt1]}
strokeWidth={3}
size={big ? 20 : 15}
/>
{typeof post.replyCount !== 'undefined' ? (
<Text style={[defaultCtrlColor, s.ml5, s.f15]}>
{post.replyCount}
</Text>
) : undefined}
</TouchableOpacity>
<RepostButton
big={big}
isReposted={!!post.viewer?.repost}
repostCount={post.repostCount}
onRepost={onRepost}
onQuote={onQuote}
/>
<TouchableOpacity
testID="likeBtn"
style={[styles.ctrl, !big && styles.ctrlPad]}
onPress={onPressToggleLike}
accessibilityRole="button"
accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
post.likeCount
} ${pluralize(post.likeCount || 0, 'like')})`}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
{post.viewer?.like ? (
<HeartIconSolid style={styles.ctrlIconLiked} size={big ? 22 : 16} />
) : (
<HeartIcon
style={[defaultCtrlColor, big ? styles.mt1 : undefined]}
strokeWidth={3}
size={big ? 20 : 16}
/>
)}
{typeof post.likeCount !== 'undefined' ? (
<Text
testID="likeCount"
style={
post.viewer?.like
? [s.bold, s.red3, s.f15, s.ml5]
: [defaultCtrlColor, s.f15, s.ml5]
}>
{post.likeCount}
</Text>
) : undefined}
</TouchableOpacity>
{big ? undefined : (
<PostDropdownBtn
testID="postDropdownBtn"
post={post}
record={record}
style={styles.ctrlPad}
/>
)}
{/* used for adding pad to the right side */}
<View />
</View>
)
}
const styles = StyleSheet.create({
ctrls: {
flexDirection: 'row',
justifyContent: 'space-between',
},
ctrl: {
flexDirection: 'row',
alignItems: 'center',
},
ctrlPad: {
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 5,
paddingRight: 5,
},
ctrlIconLiked: {
color: colors.like,
},
mt1: {
marginTop: 1,
},
})

View file

@ -1,7 +1,7 @@
import React from 'react'
import {FlatList, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {observer} from 'mobx-react-lite'
import {useQueryClient} from '@tanstack/react-query'
import {
NativeStackScreenProps,
NotificationsTabNavigatorParams,
@ -13,21 +13,21 @@ import {TextLink} from 'view/com/util/Link'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {useStores} from 'state/index'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics'
import {isWeb} from 'platform/detection'
import {logger} from '#/logger'
import {useSetMinimalShellMode} from '#/state/shell'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
type Props = NativeStackScreenProps<
NotificationsTabNavigatorParams,
'Notifications'
>
export const NotificationsScreen = withAuthRequired(
observer(function NotificationsScreenImpl({}: Props) {
function NotificationsScreenImpl({}: Props) {
const store = useStores()
const setMinimalShellMode = useSetMinimalShellMode()
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
@ -35,17 +35,12 @@ export const NotificationsScreen = withAuthRequired(
const {screen} = useAnalytics()
const pal = usePalette('default')
const {isDesktop} = useWebMediaQueries()
const hasNew =
store.me.notifications.hasNewLatest &&
!store.me.notifications.isRefreshing
const unreadNotifs = useUnreadNotifications()
const queryClient = useQueryClient()
const hasNew = !!unreadNotifs
// event handlers
// =
const onPressTryAgain = React.useCallback(() => {
store.me.notifications.refresh()
}, [store])
const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0})
resetMainScroll()
@ -53,8 +48,8 @@ export const NotificationsScreen = withAuthRequired(
const onPressLoadLatest = React.useCallback(() => {
scrollToTop()
store.me.notifications.refresh()
}, [store, scrollToTop])
queryClient.invalidateQueries({queryKey: NOTIFS_RQKEY()})
}, [scrollToTop, queryClient])
// on-visible setup
// =
@ -63,42 +58,14 @@ export const NotificationsScreen = withAuthRequired(
setMinimalShellMode(false)
logger.debug('NotificationsScreen: Updating feed')
const softResetSub = store.onScreenSoftReset(onPressLoadLatest)
store.me.notifications.update()
screen('Notifications')
return () => {
softResetSub.remove()
store.me.notifications.markAllRead()
}
}, [store, screen, onPressLoadLatest, setMinimalShellMode]),
)
useTabFocusEffect(
'Notifications',
React.useCallback(
isInside => {
// on mobile:
// fires with `isInside=true` when the user navigates to the root tab
// but not when the user goes back to the screen by pressing back
// on web:
// essentially equivalent to useFocusEffect because we dont used tabbed
// navigation
if (isInside) {
if (isWeb) {
store.me.notifications.syncQueue()
} else {
if (store.me.notifications.unreadCount > 0) {
store.me.notifications.refresh()
} else {
store.me.notifications.syncQueue()
}
}
}
},
[store],
),
)
const ListHeaderComponent = React.useCallback(() => {
if (isDesktop) {
return (
@ -145,8 +112,6 @@ export const NotificationsScreen = withAuthRequired(
<View testID="notificationsScreen" style={s.hContentRegion}>
<ViewHeader title="Notifications" canGoBack={false} />
<Feed
view={store.me.notifications}
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
scrollElRef={scrollElRef}
ListHeaderComponent={ListHeaderComponent}
@ -160,5 +125,5 @@ export const NotificationsScreen = withAuthRequired(
)}
</View>
)
}),
},
)

View file

@ -49,6 +49,7 @@ import {useSetDrawerOpen} from '#/state/shell'
import {useModalControls} from '#/state/modals'
import {useSession, SessionAccount} from '#/state/session'
import {useProfileQuery} from '#/state/queries/profile'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
export function DrawerProfileCard({
account,
@ -110,8 +111,7 @@ export const DrawerContent = observer(function DrawerContentImpl() {
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
useNavigationTabState()
const {currentAccount} = useSession()
const {notifications} = store.me
const numUnreadNotifications = useUnreadNotifications()
// events
// =
@ -286,11 +286,11 @@ export const DrawerContent = observer(function DrawerContentImpl() {
label="Notifications"
accessibilityLabel={_(msg`Notifications`)}
accessibilityHint={
notifications.unreadCountLabel === ''
numUnreadNotifications === ''
? ''
: `${notifications.unreadCountLabel} unread`
: `${numUnreadNotifications} unread`
}
count={notifications.unreadCountLabel}
count={numUnreadNotifications}
bold={isAtNotifications}
onPress={onPressNotifications}
/>

View file

@ -28,6 +28,7 @@ import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useModalControls} from '#/state/modals'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
@ -43,9 +44,8 @@ export const BottomBar = observer(function BottomBarImpl({
const {footerHeight} = useShellLayout()
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
useNavigationTabState()
const numUnreadNotifications = useUnreadNotifications()
const {footerMinimalShellTransform} = useMinimalShellMode()
const {notifications} = store.me
const onPressTab = React.useCallback(
(tab: TabOptions) => {
@ -178,14 +178,14 @@ export const BottomBar = observer(function BottomBarImpl({
)
}
onPress={onPressNotifications}
notificationCount={notifications.unreadCountLabel}
notificationCount={numUnreadNotifications}
accessible={true}
accessibilityRole="tab"
accessibilityLabel={_(msg`Notifications`)}
accessibilityHint={
notifications.unreadCountLabel === ''
numUnreadNotifications === ''
? ''
: `${notifications.unreadCountLabel} unread`
: `${numUnreadNotifications} unread`
}
/>
<Btn

View file

@ -43,6 +43,7 @@ import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
const ProfileCard = observer(function ProfileCardImpl() {
const {currentAccount} = useSession()
@ -253,6 +254,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
const store = useStores()
const pal = usePalette('default')
const {isDesktop, isTablet} = useWebMediaQueries()
const numUnread = useUnreadNotifications()
return (
<View
@ -314,7 +316,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
/>
<NavItem
href="/notifications"
count={store.me.notifications.unreadCountLabel}
count={numUnread}
icon={
<BellIcon
strokeWidth={2}