diff options
Diffstat (limited to 'src/view/com/posts')
-rw-r--r-- | src/view/com/posts/Feed.tsx | 182 | ||||
-rw-r--r-- | src/view/com/posts/FeedErrorMessage.tsx | 198 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 273 | ||||
-rw-r--r-- | src/view/com/posts/FeedSlice.tsx | 95 |
4 files changed, 469 insertions, 279 deletions
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 1ecb14912..f0f7cd919 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -1,7 +1,7 @@ -import React, {MutableRefObject} from 'react' -import {observer} from 'mobx-react-lite' +import React, {memo, MutableRefObject} from 'react' import { ActivityIndicator, + Dimensions, RefreshControl, StyleProp, StyleSheet, @@ -11,26 +11,36 @@ import { import {FlatList} from '../util/Views' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {FeedErrorMessage} from './FeedErrorMessage' -import {PostsFeedModel} from 'state/models/feeds/posts' import {FeedSlice} from './FeedSlice' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' -import {s} from 'lib/styles' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useTheme} from 'lib/ThemeContext' import {logger} from '#/logger' +import { + FeedDescriptor, + FeedParams, + usePostFeedQuery, + pollLatest, +} from '#/state/queries/post-feed' +import {useModerationOpts} from '#/state/queries/preferences' const LOADING_ITEM = {_reactKey: '__loading__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -export const Feed = observer(function Feed({ +let Feed = ({ feed, + feedParams, style, + enabled, + pollInterval, scrollElRef, onScroll, + onHasNew, scrollEventThrottle, renderEmptyState, renderEndOfFeed, @@ -40,10 +50,14 @@ export const Feed = observer(function Feed({ ListHeaderComponent, extraData, }: { - feed: PostsFeedModel + feed: FeedDescriptor + feedParams?: FeedParams style?: StyleProp<ViewStyle> + enabled?: boolean + pollInterval?: number scrollElRef?: MutableRefObject<FlatList<any> | null> - onScroll?: OnScrollCb + onHasNew?: (v: boolean) => void + onScroll?: OnScrollHandler scrollEventThrottle?: number renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element @@ -52,70 +66,110 @@ export const Feed = observer(function Feed({ desktopFixedHeightOffset?: number ListHeaderComponent?: () => JSX.Element extraData?: any -}) { +}): React.ReactNode => { 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 moderationOpts = useModerationOpts() + const opts = React.useMemo(() => ({enabled}), [enabled]) + const { + data, + isFetching, + isFetched, + isError, + error, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = usePostFeedQuery(feed, feedParams, opts) + const isEmpty = !isFetching && !data?.pages[0]?.slices.length + + const checkForNew = React.useCallback(async () => { + if (!data?.pages[0] || isFetching || !onHasNew || !enabled) { + return + } + try { + if (await pollLatest(data.pages[0])) { + onHasNew(true) + } + } catch (e) { + logger.error('Poll latest failed', {feed, error: String(e)}) + } + }, [feed, data, isFetching, onHasNew, enabled]) + + React.useEffect(() => { + // we store the interval handler in a ref to avoid needless + // reassignments of the interval + checkForNewRef.current = checkForNew + }, [checkForNew]) + React.useEffect(() => { + if (!pollInterval) { + return + } + const i = setInterval(() => checkForNewRef.current?.(), pollInterval) + return () => clearInterval(i) + }, [pollInterval]) - const data = React.useMemo(() => { - let feedItems: any[] = [] - if (feed.hasLoaded) { - if (feed.hasError) { - feedItems = feedItems.concat([ERROR_ITEM]) + const feedItems = React.useMemo(() => { + let arr: any[] = [] + if (isFetched && moderationOpts) { + if (isError && isEmpty) { + arr = arr.concat([ERROR_ITEM]) } - if (feed.isEmpty) { - feedItems = feedItems.concat([EMPTY_FEED_ITEM]) - } else { - feedItems = feedItems.concat(feed.slices) + if (isEmpty) { + arr = arr.concat([EMPTY_FEED_ITEM]) + } else if (data) { + for (const page of data?.pages) { + arr = arr.concat(page.slices) + } } - if (feed.loadMoreError) { - feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM]) + if (isError && !isEmpty) { + arr = arr.concat([LOAD_MORE_ERROR_ITEM]) } } else { - feedItems.push(LOADING_ITEM) + arr.push(LOADING_ITEM) } - return feedItems - }, [ - feed.hasError, - feed.hasLoaded, - feed.isEmpty, - feed.slices, - feed.loadMoreError, - ]) + return arr + }, [isFetched, isError, isEmpty, data, moderationOpts]) // events // = const onRefresh = React.useCallback(async () => { track('Feed:onRefresh') - setIsRefreshing(true) + setIsPTRing(true) try { - await feed.refresh() + await refetch() + onHasNew?.(false) } catch (err) { logger.error('Failed to refresh posts feed', {error: err}) } - setIsRefreshing(false) - }, [feed, track, setIsRefreshing]) + setIsPTRing(false) + }, [refetch, track, setIsPTRing, onHasNew]) const onEndReached = React.useCallback(async () => { - if (!feed.hasLoaded || !feed.hasMore) return + if (isFetching || !hasNextPage || isError) return track('Feed:onEndReached') try { - await feed.loadMore() + await fetchNextPage() } catch (err) { logger.error('Failed to load more posts', {error: err}) } - }, [feed, track]) + }, [isFetching, hasNextPage, isError, fetchNextPage, track]) const onPressTryAgain = React.useCallback(() => { - feed.refresh() - }, [feed]) + refetch() + onHasNew?.(false) + }, [refetch, onHasNew]) const onPressRetryLoadMore = React.useCallback(() => { - feed.retryLoadMore() - }, [feed]) + fetchNextPage() + }, [fetchNextPage]) // rendering // = @@ -126,7 +180,11 @@ export const Feed = observer(function Feed({ return renderEmptyState() } else if (item === ERROR_ITEM) { return ( - <FeedErrorMessage feed={feed} onPressTryAgain={onPressTryAgain} /> + <FeedErrorMessage + feedDesc={feed} + error={error} + onPressTryAgain={onPressTryAgain} + /> ) } else if (item === LOAD_MORE_ERROR_ITEM) { return ( @@ -138,47 +196,65 @@ export const Feed = observer(function Feed({ } else if (item === LOADING_ITEM) { return <PostFeedLoadingPlaceholder /> } - return <FeedSlice slice={item} /> + return ( + <FeedSlice + slice={item} + // we check for this before creating the feedItems array + moderationOpts={moderationOpts!} + /> + ) }, - [feed, onPressTryAgain, onPressRetryLoadMore, renderEmptyState], + [ + feed, + error, + onPressTryAgain, + onPressRetryLoadMore, + renderEmptyState, + moderationOpts, + ], ) + const shouldRenderEndOfFeed = + !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed const FeedFooter = React.useCallback( () => - feed.isLoadingMore ? ( + isFetchingNextPage ? ( <View style={styles.feedFooter}> <ActivityIndicator /> </View> - ) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? ( + ) : shouldRenderEndOfFeed ? ( renderEndOfFeed() ) : ( <View /> ), - [feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed], + [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed], ) + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) return ( <View testID={testID} style={style}> <FlatList testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} - data={data} + data={feedItems} keyExtractor={item => item._reactKey} renderItem={renderItem} ListFooterComponent={FeedFooter} ListHeaderComponent={ListHeaderComponent} refreshControl={ <RefreshControl - refreshing={isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} progressViewOffset={headerOffset} /> } - contentContainerStyle={s.contentContainer} + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} style={{paddingTop: headerOffset}} - onScroll={onScroll} + onScroll={onScroll != null ? scrollHandler : undefined} scrollEventThrottle={scrollEventThrottle} indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} onEndReached={onEndReached} @@ -193,7 +269,9 @@ export const Feed = observer(function Feed({ /> </View> ) -}) +} +Feed = memo(Feed) +export {Feed} const styles = StyleSheet.create({ feedFooter: {paddingTop: 20}, diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index 9e75d9507..63d9d5956 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -1,7 +1,6 @@ import React from 'react' import {View} from 'react-native' -import {AtUri, AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' -import {PostsFeedModel, KnownError} from 'state/models/feeds/posts' +import {AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import * as Toast from '../util/Toast' @@ -9,67 +8,118 @@ import {ErrorMessage} from '../util/error/ErrorMessage' import {usePalette} from 'lib/hooks/usePalette' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' -import {useStores} from 'state/index' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' +import {msg as msgLingui} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {FeedDescriptor} from '#/state/queries/post-feed' +import {EmptyState} from '../util/EmptyState' +import {cleanError} from '#/lib/strings/errors' +import {useRemoveFeedMutation} from '#/state/queries/preferences' -const MESSAGES = { - [KnownError.Unknown]: '', - [KnownError.FeedgenDoesNotExist]: `Hmmm, we're having trouble finding this feed. It may have been deleted.`, - [KnownError.FeedgenMisconfigured]: - 'Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.', - [KnownError.FeedgenBadResponse]: - 'Hmm, the feed server gave a bad response. Please let the feed owner know about this issue.', - [KnownError.FeedgenOffline]: - 'Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.', - [KnownError.FeedgenUnknown]: - 'Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue.', +export enum KnownError { + Block = 'Block', + FeedgenDoesNotExist = 'FeedgenDoesNotExist', + FeedgenMisconfigured = 'FeedgenMisconfigured', + FeedgenBadResponse = 'FeedgenBadResponse', + FeedgenOffline = 'FeedgenOffline', + FeedgenUnknown = 'FeedgenUnknown', + FeedNSFPublic = 'FeedNSFPublic', + Unknown = 'Unknown', } export function FeedErrorMessage({ - feed, + feedDesc, + error, onPressTryAgain, }: { - feed: PostsFeedModel + feedDesc: FeedDescriptor + error: any onPressTryAgain: () => void }) { + const knownError = React.useMemo( + () => detectKnownError(feedDesc, error), + [feedDesc, error], + ) if ( - typeof feed.knownError === 'undefined' || - feed.knownError === KnownError.Unknown + typeof knownError !== 'undefined' && + knownError !== KnownError.Unknown && + feedDesc.startsWith('feedgen') ) { + return <FeedgenErrorMessage feedDesc={feedDesc} knownError={knownError} /> + } + + if (knownError === KnownError.Block) { return ( - <ErrorMessage message={feed.error} onPressTryAgain={onPressTryAgain} /> + <EmptyState + icon="ban" + message="Posts hidden" + style={{paddingVertical: 40}} + /> ) } - return <FeedgenErrorMessage feed={feed} knownError={feed.knownError} /> + return ( + <ErrorMessage + message={cleanError(error)} + onPressTryAgain={onPressTryAgain} + /> + ) } function FeedgenErrorMessage({ - feed, + feedDesc, knownError, }: { - feed: PostsFeedModel + feedDesc: FeedDescriptor knownError: KnownError }) { const pal = usePalette('default') - const store = useStores() + const {_: _l} = useLingui() const navigation = useNavigation<NavigationProp>() - const msg = MESSAGES[knownError] - const uri = (feed.params as GetCustomFeed.QueryParams).feed + const msg = React.useMemo( + () => + ({ + [KnownError.Unknown]: '', + [KnownError.Block]: '', + [KnownError.FeedgenDoesNotExist]: _l( + msgLingui`Hmmm, we're having trouble finding this feed. It may have been deleted.`, + ), + [KnownError.FeedgenMisconfigured]: _l( + msgLingui`Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.`, + ), + [KnownError.FeedgenBadResponse]: _l( + msgLingui`Hmm, the feed server gave a bad response. Please let the feed owner know about this issue.`, + ), + [KnownError.FeedgenOffline]: _l( + msgLingui`Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.`, + ), + [KnownError.FeedNSFPublic]: _l( + msgLingui`We're sorry, but this content is not viewable without a Bluesky account.`, + ), + [KnownError.FeedgenUnknown]: _l( + msgLingui`Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue.`, + ), + }[knownError]), + [_l, knownError], + ) + const [_, uri] = feedDesc.split('|') const [ownerDid] = safeParseFeedgenUri(uri) + const {openModal, closeModal} = useModalControls() + const {mutateAsync: removeFeed} = useRemoveFeedMutation() const onViewProfile = React.useCallback(() => { navigation.navigate('Profile', {name: ownerDid}) }, [navigation, ownerDid]) const onRemoveFeed = React.useCallback(async () => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Remove feed', - message: 'Remove this feed from your saved feeds?', + title: _l(msgLingui`Remove feed`), + message: _l(msgLingui`Remove this feed from your saved feeds?`), async onPressConfirm() { try { - await store.preferences.removeSavedFeed(uri) + await removeFeed({uri}) } catch (err) { Toast.show( 'There was an an issue removing this feed. Please check your internet connection and try again.', @@ -78,10 +128,40 @@ function FeedgenErrorMessage({ } }, onPressCancel() { - store.shell.closeModal() + closeModal() }, }) - }, [store, uri]) + }, [openModal, closeModal, uri, removeFeed, _l]) + + const cta = React.useMemo(() => { + switch (knownError) { + case KnownError.FeedNSFPublic: { + return null + } + case KnownError.FeedgenDoesNotExist: + case KnownError.FeedgenMisconfigured: + case KnownError.FeedgenBadResponse: + case KnownError.FeedgenOffline: + case KnownError.FeedgenUnknown: { + return ( + <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> + {knownError === KnownError.FeedgenDoesNotExist && ( + <Button + type="inverted" + label="Remove feed" + onPress={onRemoveFeed} + /> + )} + <Button + type="default-light" + label="View profile" + onPress={onViewProfile} + /> + </View> + ) + } + } + }, [knownError, onViewProfile, onRemoveFeed]) return ( <View @@ -96,16 +176,7 @@ function FeedgenErrorMessage({ }, ]}> <Text style={pal.text}>{msg}</Text> - <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> - {knownError === KnownError.FeedgenDoesNotExist && ( - <Button type="inverted" label="Remove feed" onPress={onRemoveFeed} /> - )} - <Button - type="default-light" - label="View profile" - onPress={onViewProfile} - /> - </View> + {cta} </View> ) } @@ -118,3 +189,48 @@ function safeParseFeedgenUri(uri: string): [string, string] { return ['', ''] } } + +function detectKnownError( + feedDesc: FeedDescriptor, + error: any, +): KnownError | undefined { + if (!error) { + return undefined + } + if ( + error instanceof AppBskyFeedGetAuthorFeed.BlockedActorError || + error instanceof AppBskyFeedGetAuthorFeed.BlockedByActorError + ) { + return KnownError.Block + } + if (typeof error !== 'string') { + error = error.toString() + } + if (!feedDesc.startsWith('feedgen')) { + return KnownError.Unknown + } + if (error.includes('could not find feed')) { + return KnownError.FeedgenDoesNotExist + } + if (error.includes('feed unavailable')) { + return KnownError.FeedgenOffline + } + if (error.includes('invalid did document')) { + return KnownError.FeedgenMisconfigured + } + if (error.includes('could not resolve did document')) { + return KnownError.FeedgenMisconfigured + } + if ( + error.includes('invalid feed generator service details in did document') + ) { + return KnownError.FeedgenMisconfigured + } + if (error.includes('feed provided an invalid response')) { + return KnownError.FeedgenBadResponse + } + if (error.includes(KnownError.FeedNSFPublic)) { + return KnownError.FeedNSFPublic + } + return KnownError.FeedgenUnknown +} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index aeee3e20a..dfb0cfcf6 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -1,14 +1,17 @@ -import React, {useMemo, useState} from 'react' -import {observer} from 'mobx-react-lite' -import {Linking, StyleSheet, View} from 'react-native' -import Clipboard from '@react-native-clipboard/clipboard' -import {AtUri} from '@atproto/api' +import React, {memo, useMemo, useState} from 'react' +import {StyleSheet, View} from 'react-native' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + PostModeration, + RichText as RichTextAPI, +} from '@atproto/api' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {PostsFeedItemModel} from 'state/models/feeds/post' -import {FeedSourceInfo} from 'lib/api/feed/types' +import {ReasonFeedSource, isReasonFeedSource} from 'lib/api/feed/types' import {Link, TextLinkOnWebOnly, TextLink} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' @@ -19,50 +22,96 @@ import {ContentHider} from '../util/moderation/ContentHider' import {PostAlerts} from '../util/moderation/PostAlerts' import {RichText} from '../util/text/RichText' import {PostSandboxWarning} from '../util/PostSandboxWarning' -import * as Toast from '../util/Toast' import {PreviewableUserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' -import {getTranslatorLink} from '../../../locale/helpers' import {makeProfileLink} from 'lib/routes/links' import {isEmbedByEmbedder} from 'lib/embeds' import {MAX_POST_LINES} from 'lib/constants' import {countLines} from 'lib/strings/helpers' -import {logger} from '#/logger' +import {useComposerControls} from '#/state/shell/composer' +import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' -export const FeedItem = observer(function FeedItemImpl({ - item, - source, +export function FeedItem({ + post, + record, + reason, + moderation, isThreadChild, isThreadLastChild, isThreadParent, }: { - item: PostsFeedItemModel - source?: FeedSourceInfo + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record + reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined + moderation: PostModeration isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean - showReplyLine?: boolean }) { - const store = useStores() + const postShadowed = usePostShadow(post) + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + if (postShadowed === POST_TOMBSTONE) { + return null + } + if (richText && moderation) { + return ( + <FeedItemInner + post={postShadowed} + record={record} + reason={reason} + richText={richText} + moderation={moderation} + isThreadChild={isThreadChild} + isThreadLastChild={isThreadLastChild} + isThreadParent={isThreadParent} + /> + ) + } + return null +} + +let FeedItemInner = ({ + post, + record, + reason, + richText, + moderation, + isThreadChild, + isThreadLastChild, + isThreadParent, +}: { + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record + reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined + richText: RichTextAPI + moderation: PostModeration + isThreadChild?: boolean + isThreadLastChild?: boolean + isThreadParent?: boolean +}): React.ReactNode => { + const {openComposer} = useComposerControls() const pal = usePalette('default') const {track} = useAnalytics() - const [deleted, setDeleted] = useState(false) const [limitLines, setLimitLines] = useState( - countLines(item.richText?.text) >= MAX_POST_LINES, + () => countLines(richText.text) >= MAX_POST_LINES, ) - const record = item.postRecord - const itemUri = item.post.uri - const itemCid = item.post.cid - const itemHref = useMemo(() => { - const urip = new AtUri(item.post.uri) - return makeProfileLink(item.post.author, 'post', urip.rkey) - }, [item.post.uri, item.post.author]) - const itemTitle = `Post by ${item.post.author.handle}` + + const href = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + const replyAuthorDid = useMemo(() => { if (!record?.reply) { return '' @@ -70,77 +119,22 @@ export const FeedItem = observer(function FeedItemImpl({ const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) return urip.hostname }, [record?.reply]) - const translatorUrl = getTranslatorLink( - record?.text || '', - store.preferences.primaryLanguage, - ) const onPressReply = React.useCallback(() => { track('FeedItem:PostReply') - store.shell.openComposer({ + openComposer({ replyTo: { - uri: item.post.uri, - cid: item.post.cid, - text: record?.text || '', + 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, }, }, }) - }, [item, track, record, store]) - - const onPressToggleRepost = React.useCallback(() => { - track('FeedItem:PostRepost') - return item - .toggleRepost() - .catch(e => logger.error('Failed to toggle repost', {error: e})) - }, [track, item]) - - const onPressToggleLike = React.useCallback(() => { - track('FeedItem:PostLike') - return item - .toggleLike() - .catch(e => logger.error('Failed to toggle like', {error: e})) - }, [track, 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(async () => { - track('FeedItem:ThreadMute') - try { - await item.toggleThreadMute() - if (item.isThreadMuted) { - 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}) - } - }, [track, item]) - - const onDeletePost = React.useCallback(() => { - track('FeedItem:PostDelete') - 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') - }, - ) - }, [track, item, setDeleted]) + }, [post, record, track, openComposer]) const onPressShowMore = React.useCallback(() => { setLimitLines(false) @@ -159,15 +153,11 @@ export const FeedItem = observer(function FeedItemImpl({ isThreadChild ? styles.outerSmallTop : undefined, ] - if (!record || deleted) { - return <View /> - } - return ( <Link - testID={`feedItem-by-${item.post.author.handle}`} + testID={`feedItem-by-${post.author.handle}`} style={outerStyles} - href={itemHref} + href={href} noFeedback accessible={false}> <PostSandboxWarning /> @@ -189,10 +179,10 @@ export const FeedItem = observer(function FeedItemImpl({ </View> <View style={{paddingTop: 12, flexShrink: 1}}> - {source ? ( + {isReasonFeedSource(reason) ? ( <Link - title={sanitizeDisplayName(source.displayName)} - href={source.uri}> + title={sanitizeDisplayName(reason.displayName)} + href={reason.uri}> <Text type="sm-bold" style={pal.textLight} @@ -204,17 +194,17 @@ export const FeedItem = observer(function FeedItemImpl({ style={pal.textLight} lineHeight={1.2} numberOfLines={1} - text={sanitizeDisplayName(source.displayName)} - href={source.uri} + text={sanitizeDisplayName(reason.displayName)} + href={reason.uri} /> </Text> </Link> - ) : item.reasonRepost ? ( + ) : AppBskyFeedDefs.isReasonRepost(reason) ? ( <Link style={styles.includeReason} - href={makeProfileLink(item.reasonRepost.by)} + href={makeProfileLink(reason.by)} title={`Reposted by ${sanitizeDisplayName( - item.reasonRepost.by.displayName || item.reasonRepost.by.handle, + reason.by.displayName || reason.by.handle, )}`}> <FontAwesomeIcon icon="retweet" @@ -236,10 +226,9 @@ export const FeedItem = observer(function FeedItemImpl({ lineHeight={1.2} numberOfLines={1} text={sanitizeDisplayName( - item.reasonRepost.by.displayName || - sanitizeHandle(item.reasonRepost.by.handle), + reason.by.displayName || sanitizeHandle(reason.by.handle), )} - href={makeProfileLink(item.reasonRepost.by)} + href={makeProfileLink(reason.by)} /> </Text> </Link> @@ -251,10 +240,10 @@ export const FeedItem = observer(function FeedItemImpl({ <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} /> {isThreadParent && ( <View @@ -271,10 +260,10 @@ export const FeedItem = observer(function FeedItemImpl({ </View> <View style={styles.layoutContent}> <PostMeta - author={item.post.author} - authorHasWarning={!!item.post.author.labels?.length} - timestamp={item.post.indexedAt} - postHref={itemHref} + author={post.author} + authorHasWarning={!!post.author.labels?.length} + timestamp={post.indexedAt} + postHref={href} /> {!isThreadChild && replyAuthorDid !== '' && ( <View style={[s.flexRow, s.mb2, s.alignCenter]}> @@ -303,19 +292,16 @@ export const FeedItem = observer(function FeedItemImpl({ )} <ContentHider testID="contentHider-post" - moderation={item.moderation.content} + moderation={moderation.content} ignoreMute 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} @@ -330,50 +316,23 @@ export const FeedItem = observer(function FeedItemImpl({ href="#" /> ) : undefined} - {item.post.embed ? ( + {post.embed ? ( <ContentHider testID="contentHider-embed" - moderation={item.moderation.embed} - ignoreMute={isEmbedByEmbedder( - item.post.embed, - item.post.author.did, - )} + moderation={moderation.embed} + ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} style={styles.embed}> - <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} - text={item.richText?.text || record.text} - indexedAt={item.post.indexedAt} - 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={item.isThreadMuted} - onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} - /> + <PostCtrls post={post} record={record} onPressReply={onPressReply} /> </View> </View> </Link> ) -}) +} +FeedItemInner = memo(FeedItemInner) const styles = StyleSheet.create({ outer: { diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 1d26f6cbd..a3bacdc1e 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -1,8 +1,7 @@ -import React from 'react' +import React, {memo} from 'react' import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' -import {AtUri} from '@atproto/api' +import {FeedPostSlice} from '#/state/queries/post-feed' +import {AtUri, moderatePost, ModerationOpts} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import Svg, {Circle, Line} from 'react-native-svg' @@ -10,15 +9,27 @@ import {FeedItem} from './FeedItem' import {usePalette} from 'lib/hooks/usePalette' import {makeProfileLink} from 'lib/routes/links' -export const FeedSlice = observer(function FeedSliceImpl({ +let FeedSlice = ({ slice, ignoreFilterFor, + moderationOpts, }: { - slice: PostsFeedSliceModel + slice: FeedPostSlice ignoreFilterFor?: string -}) { - if (slice.shouldFilter(ignoreFilterFor)) { - return null + moderationOpts: ModerationOpts +}): React.ReactNode => { + const moderations = React.useMemo(() => { + return slice.items.map(item => moderatePost(item.post, moderationOpts)) + }, [slice, moderationOpts]) + + // apply moderation filter + for (let i = 0; i < slice.items.length; i++) { + if ( + moderations[i]?.content.filter && + slice.items[i].post.author.did !== ignoreFilterFor + ) { + return null + } } if (slice.isThread && slice.items.length > 3) { @@ -27,23 +38,31 @@ export const FeedSlice = observer(function FeedSliceImpl({ <> <FeedItem key={slice.items[0]._reactKey} - item={slice.items[0]} - source={slice.source} - isThreadParent={slice.isThreadParentAt(0)} - isThreadChild={slice.isThreadChildAt(0)} + post={slice.items[0].post} + record={slice.items[0].record} + reason={slice.items[0].reason} + moderation={moderations[0]} + isThreadParent={isThreadParentAt(slice.items, 0)} + isThreadChild={isThreadChildAt(slice.items, 0)} /> <FeedItem key={slice.items[1]._reactKey} - item={slice.items[1]} - isThreadParent={slice.isThreadParentAt(1)} - isThreadChild={slice.isThreadChildAt(1)} + post={slice.items[1].post} + record={slice.items[1].record} + reason={slice.items[1].reason} + moderation={moderations[1]} + isThreadParent={isThreadParentAt(slice.items, 1)} + isThreadChild={isThreadChildAt(slice.items, 1)} /> <ViewFullThread slice={slice} /> <FeedItem key={slice.items[last]._reactKey} - item={slice.items[last]} - isThreadParent={slice.isThreadParentAt(last)} - isThreadChild={slice.isThreadChildAt(last)} + post={slice.items[last].post} + record={slice.items[last].record} + reason={slice.items[last].reason} + moderation={moderations[last]} + isThreadParent={isThreadParentAt(slice.items, last)} + isThreadChild={isThreadChildAt(slice.items, last)} isThreadLastChild /> </> @@ -55,25 +74,29 @@ export const FeedSlice = observer(function FeedSliceImpl({ {slice.items.map((item, i) => ( <FeedItem key={item._reactKey} - item={item} - source={i === 0 ? slice.source : undefined} - isThreadParent={slice.isThreadParentAt(i)} - isThreadChild={slice.isThreadChildAt(i)} + post={slice.items[i].post} + record={slice.items[i].record} + reason={slice.items[i].reason} + moderation={moderations[i]} + isThreadParent={isThreadParentAt(slice.items, i)} + isThreadChild={isThreadChildAt(slice.items, i)} isThreadLastChild={ - slice.isThreadChildAt(i) && slice.items.length === i + 1 + isThreadChildAt(slice.items, i) && slice.items.length === i + 1 } /> ))} </> ) -}) +} +FeedSlice = memo(FeedSlice) +export {FeedSlice} -function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { +function ViewFullThread({slice}: {slice: FeedPostSlice}) { const pal = usePalette('default') const itemHref = React.useMemo(() => { - const urip = new AtUri(slice.rootItem.post.uri) - return makeProfileLink(slice.rootItem.post.author, 'post', urip.rkey) - }, [slice.rootItem.post.uri, slice.rootItem.post.author]) + const urip = new AtUri(slice.rootUri) + return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) + }, [slice.rootUri]) return ( <Link @@ -115,3 +138,17 @@ const styles = StyleSheet.create({ alignItems: 'center', }, }) + +function isThreadParentAt<T>(arr: Array<T>, i: number) { + if (arr.length === 1) { + return false + } + return i < arr.length - 1 +} + +function isThreadChildAt<T>(arr: Array<T>, i: number) { + if (arr.length === 1) { + return false + } + return i > 0 +} |