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:
parent
c584a3378d
commit
b445c15cc9
29 changed files with 941 additions and 1739 deletions
|
@ -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},
|
||||
})
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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>
|
||||
)
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue