Refactor feeds to use react-query (#1862)
* Update to react-query v5 * Introduce post-feed react query * Add feed refresh behaviors * Only fetch feeds of visible pages * Implement polling for latest on feeds * Add moderation filtering to slices * Handle block errors * Update feed error messages * Remove old models * Replace simple-feed option with disable-tuner option * Add missing useMemo * Implement the mergefeed and fixes to polling * Correctly handle failed load more state * Improve error and empty state behaviors * Clearer naming
This commit is contained in:
parent
51f04b9620
commit
c8c308e31e
31 changed files with 904 additions and 1081 deletions
|
@ -245,7 +245,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
if (replyTo && replyTo.uri) track('Post:Reply')
|
||||
}
|
||||
if (!replyTo) {
|
||||
store.me.mainFeed.onPostCreated()
|
||||
// TODO onPostCreated
|
||||
}
|
||||
setLangPrefs.savePostLanguageToHistory()
|
||||
onPost?.()
|
||||
|
|
|
@ -4,36 +4,38 @@ import {
|
|||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
import {useAnalytics} from '@segment/analytics-react-native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
||||
import {ComposeIcon2} from 'lib/icons'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React from 'react'
|
||||
import {FlatList, View} from 'react-native'
|
||||
import {FlatList, View, useWindowDimensions} from 'react-native'
|
||||
import {useStores} from 'state/index'
|
||||
import {PostsFeedModel} from 'state/models/feeds/posts'
|
||||
import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home'
|
||||
import {Feed} from '../posts/Feed'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {FAB} from '../util/fab/FAB'
|
||||
import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
|
||||
import useAppState from 'react-native-appstate-hook'
|
||||
import {logger} from '#/logger'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
export const FeedPage = observer(function FeedPageImpl({
|
||||
const POLL_FREQ = 30e3 // 30sec
|
||||
|
||||
export function FeedPage({
|
||||
testID,
|
||||
isPageFocused,
|
||||
feed,
|
||||
feedParams,
|
||||
renderEmptyState,
|
||||
renderEndOfFeed,
|
||||
}: {
|
||||
testID?: string
|
||||
feed: PostsFeedModel
|
||||
feed: FeedDescriptor
|
||||
feedParams?: FeedParams
|
||||
isPageFocused: boolean
|
||||
renderEmptyState: () => JSX.Element
|
||||
renderEndOfFeed?: () => JSX.Element
|
||||
|
@ -43,40 +45,13 @@ export const FeedPage = observer(function FeedPageImpl({
|
|||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
const queryClient = useQueryClient()
|
||||
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
|
||||
const {screen, track} = useAnalytics()
|
||||
const headerOffset = useHeaderOffset()
|
||||
const scrollElRef = React.useRef<FlatList>(null)
|
||||
const {appState} = useAppState({
|
||||
onForeground: () => doPoll(true),
|
||||
})
|
||||
const isScreenFocused = useIsFocused()
|
||||
const hasNew = feed.hasNewLatest && !feed.isRefreshing
|
||||
|
||||
React.useEffect(() => {
|
||||
// called on first load
|
||||
if (!feed.hasLoaded && isPageFocused) {
|
||||
feed.setup()
|
||||
}
|
||||
}, [isPageFocused, feed])
|
||||
|
||||
const doPoll = React.useCallback(
|
||||
(knownActive = false) => {
|
||||
if (
|
||||
(!knownActive && appState !== 'active') ||
|
||||
!isScreenFocused ||
|
||||
!isPageFocused
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (feed.isLoading) {
|
||||
return
|
||||
}
|
||||
logger.debug('HomeScreen: Polling for new posts')
|
||||
feed.checkForLatest()
|
||||
},
|
||||
[appState, isScreenFocused, isPageFocused, feed],
|
||||
)
|
||||
const [hasNew, setHasNew] = React.useState(false)
|
||||
|
||||
const scrollToTop = React.useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({offset: -headerOffset})
|
||||
|
@ -86,31 +61,22 @@ export const FeedPage = observer(function FeedPageImpl({
|
|||
const onSoftReset = React.useCallback(() => {
|
||||
if (isPageFocused) {
|
||||
scrollToTop()
|
||||
feed.refresh()
|
||||
queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
|
||||
setHasNew(false)
|
||||
}
|
||||
}, [isPageFocused, scrollToTop, feed])
|
||||
}, [isPageFocused, scrollToTop, queryClient, feed, setHasNew])
|
||||
|
||||
// fires when page within screen is activated/deactivated
|
||||
// - check for latest
|
||||
React.useEffect(() => {
|
||||
if (!isPageFocused || !isScreenFocused) {
|
||||
return
|
||||
}
|
||||
|
||||
const softResetSub = store.onScreenSoftReset(onSoftReset)
|
||||
const feedCleanup = feed.registerListeners()
|
||||
const pollInterval = setInterval(doPoll, POLL_FREQ)
|
||||
|
||||
screen('Feed')
|
||||
logger.debug('HomeScreen: Updating feed')
|
||||
feed.checkForLatest()
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval)
|
||||
softResetSub.remove()
|
||||
feedCleanup()
|
||||
}
|
||||
}, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
|
||||
}, [store, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
|
||||
|
||||
const onPressCompose = React.useCallback(() => {
|
||||
track('HomeScreen:PressCompose')
|
||||
|
@ -119,8 +85,9 @@ export const FeedPage = observer(function FeedPageImpl({
|
|||
|
||||
const onPressLoadLatest = React.useCallback(() => {
|
||||
scrollToTop()
|
||||
feed.refresh()
|
||||
}, [feed, scrollToTop])
|
||||
queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
|
||||
setHasNew(false)
|
||||
}, [scrollToTop, feed, queryClient, setHasNew])
|
||||
|
||||
const ListHeaderComponent = React.useCallback(() => {
|
||||
if (isDesktop) {
|
||||
|
@ -191,8 +158,12 @@ export const FeedPage = observer(function FeedPageImpl({
|
|||
<Feed
|
||||
testID={testID ? `${testID}-feed` : undefined}
|
||||
feed={feed}
|
||||
feedParams={feedParams}
|
||||
enabled={isPageFocused}
|
||||
pollInterval={POLL_FREQ}
|
||||
scrollElRef={scrollElRef}
|
||||
onScroll={onMainScroll}
|
||||
onHasNew={setHasNew}
|
||||
scrollEventThrottle={1}
|
||||
renderEmptyState={renderEmptyState}
|
||||
renderEndOfFeed={renderEndOfFeed}
|
||||
|
@ -216,4 +187,18 @@ export const FeedPage = observer(function FeedPageImpl({
|
|||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function useHeaderOffset() {
|
||||
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||
const {fontScale} = useWindowDimensions()
|
||||
if (isDesktop) {
|
||||
return 0
|
||||
}
|
||||
if (isTablet) {
|
||||
return 50
|
||||
}
|
||||
// default text takes 44px, plus 34px of pad
|
||||
// scale the 44px by the font scale
|
||||
return 34 + 44 * fontScale
|
||||
}
|
||||
|
|
|
@ -69,15 +69,11 @@ export function PostThreadItem({
|
|||
const postShadowed = usePostShadow(post, dataUpdatedAt)
|
||||
const richText = useMemo(
|
||||
() =>
|
||||
post &&
|
||||
AppBskyFeedPost.isRecord(post?.record) &&
|
||||
AppBskyFeedPost.validateRecord(post?.record).success
|
||||
? new RichTextAPI({
|
||||
text: post.record.text,
|
||||
facets: post.record.facets,
|
||||
})
|
||||
: undefined,
|
||||
[post],
|
||||
new RichTextAPI({
|
||||
text: record.text,
|
||||
facets: record.facets,
|
||||
}),
|
||||
[record],
|
||||
)
|
||||
const moderation = useMemo(
|
||||
() =>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React, {MutableRefObject} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
|
@ -12,7 +11,6 @@ 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 {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
|
||||
|
@ -21,17 +19,26 @@ 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,
|
||||
} from '#/state/queries/post-feed'
|
||||
|
||||
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({
|
||||
export function Feed({
|
||||
feed,
|
||||
feedParams,
|
||||
style,
|
||||
enabled,
|
||||
pollInterval,
|
||||
scrollElRef,
|
||||
onScroll,
|
||||
onHasNew,
|
||||
scrollEventThrottle,
|
||||
renderEmptyState,
|
||||
renderEndOfFeed,
|
||||
|
@ -41,9 +48,13 @@ 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>
|
||||
onHasNew?: (v: boolean) => void
|
||||
onScroll?: OnScrollHandler
|
||||
scrollEventThrottle?: number
|
||||
renderEmptyState: () => JSX.Element
|
||||
|
@ -58,32 +69,68 @@ export const Feed = observer(function Feed({
|
|||
const theme = useTheme()
|
||||
const {track} = useAnalytics()
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
let feedItems: any[] = []
|
||||
if (feed.hasLoaded) {
|
||||
if (feed.hasError) {
|
||||
feedItems = feedItems.concat([ERROR_ITEM])
|
||||
const opts = React.useMemo(() => ({enabled}), [enabled])
|
||||
const {
|
||||
data,
|
||||
dataUpdatedAt,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
pollLatest,
|
||||
} = usePostFeedQuery(feed, feedParams, opts)
|
||||
const isEmpty = !isFetching && !data?.pages[0]?.slices.length
|
||||
|
||||
const checkForNew = React.useCallback(async () => {
|
||||
if (!isFetched || isFetching || !onHasNew) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (await pollLatest()) {
|
||||
onHasNew(true)
|
||||
}
|
||||
if (feed.isEmpty) {
|
||||
feedItems = feedItems.concat([EMPTY_FEED_ITEM])
|
||||
} else {
|
||||
feedItems = feedItems.concat(feed.slices)
|
||||
} catch (e) {
|
||||
logger.error('Poll latest failed', {feed, error: String(e)})
|
||||
}
|
||||
}, [feed, isFetched, isFetching, pollLatest, onHasNew])
|
||||
|
||||
React.useEffect(() => {
|
||||
// we store the interval handler in a ref to avoid needless
|
||||
// reassignments of the interval
|
||||
checkForNewRef.current = checkForNew
|
||||
}, [checkForNew])
|
||||
React.useEffect(() => {
|
||||
const i = setInterval(() => checkForNewRef.current?.(), pollInterval)
|
||||
return () => clearInterval(i)
|
||||
}, [pollInterval])
|
||||
|
||||
const feedItems = React.useMemo(() => {
|
||||
let arr: any[] = []
|
||||
if (isFetched) {
|
||||
if (isError && isEmpty) {
|
||||
arr = arr.concat([ERROR_ITEM])
|
||||
}
|
||||
if (feed.loadMoreError) {
|
||||
feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM])
|
||||
if (isEmpty) {
|
||||
arr = arr.concat([EMPTY_FEED_ITEM])
|
||||
} else if (data) {
|
||||
for (const page of data?.pages) {
|
||||
arr = arr.concat(page.slices)
|
||||
}
|
||||
}
|
||||
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])
|
||||
|
||||
// events
|
||||
// =
|
||||
|
@ -92,31 +139,33 @@ export const Feed = observer(function Feed({
|
|||
track('Feed:onRefresh')
|
||||
setIsRefreshing(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])
|
||||
}, [refetch, track, setIsRefreshing, 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
|
||||
// =
|
||||
|
@ -127,7 +176,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 (
|
||||
|
@ -139,23 +192,32 @@ export const Feed = observer(function Feed({
|
|||
} else if (item === LOADING_ITEM) {
|
||||
return <PostFeedLoadingPlaceholder />
|
||||
}
|
||||
return <FeedSlice slice={item} />
|
||||
return <FeedSlice slice={item} dataUpdatedAt={dataUpdatedAt} />
|
||||
},
|
||||
[feed, onPressTryAgain, onPressRetryLoadMore, renderEmptyState],
|
||||
[
|
||||
feed,
|
||||
dataUpdatedAt,
|
||||
error,
|
||||
onPressTryAgain,
|
||||
onPressRetryLoadMore,
|
||||
renderEmptyState,
|
||||
],
|
||||
)
|
||||
|
||||
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 || {})
|
||||
|
@ -164,7 +226,7 @@ export const Feed = observer(function Feed({
|
|||
<FlatList
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
data={data}
|
||||
data={feedItems}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={FeedFooter}
|
||||
|
@ -197,7 +259,7 @@ export const Feed = observer(function Feed({
|
|||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
feedFooter: {paddingTop: 20},
|
||||
|
|
|
@ -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'
|
||||
|
@ -12,9 +11,22 @@ import {NavigationProp} from 'lib/routes/types'
|
|||
import {useStores} from 'state/index'
|
||||
import {logger} from '#/logger'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {EmptyState} from '../util/EmptyState'
|
||||
|
||||
enum KnownError {
|
||||
Block,
|
||||
FeedgenDoesNotExist,
|
||||
FeedgenMisconfigured,
|
||||
FeedgenBadResponse,
|
||||
FeedgenOffline,
|
||||
FeedgenUnknown,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
const MESSAGES = {
|
||||
[KnownError.Unknown]: '',
|
||||
[KnownError.Block]: '',
|
||||
[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.',
|
||||
|
@ -27,36 +39,51 @@ const MESSAGES = {
|
|||
}
|
||||
|
||||
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={error} onPressTryAgain={onPressTryAgain} />
|
||||
}
|
||||
|
||||
function FeedgenErrorMessage({
|
||||
feed,
|
||||
feedDesc,
|
||||
knownError,
|
||||
}: {
|
||||
feed: PostsFeedModel
|
||||
feedDesc: FeedDescriptor
|
||||
knownError: KnownError
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const msg = MESSAGES[knownError]
|
||||
const uri = (feed.params as GetCustomFeed.QueryParams).feed
|
||||
const [_, uri] = feedDesc.split('|')
|
||||
const [ownerDid] = safeParseFeedgenUri(uri)
|
||||
const {openModal, closeModal} = useModalControls()
|
||||
|
||||
|
@ -120,3 +147,45 @@ 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
|
||||
}
|
||||
return KnownError.FeedgenUnknown
|
||||
}
|
||||
|
|
|
@ -1,25 +1,27 @@
|
|||
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 {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'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls2'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
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'
|
||||
|
@ -27,47 +29,91 @@ 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 {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
|
||||
|
||||
export const FeedItem = observer(function FeedItemImpl({
|
||||
item,
|
||||
source,
|
||||
export function FeedItem({
|
||||
post,
|
||||
record,
|
||||
reason,
|
||||
moderation,
|
||||
dataUpdatedAt,
|
||||
isThreadChild,
|
||||
isThreadLastChild,
|
||||
isThreadParent,
|
||||
}: {
|
||||
item: PostsFeedItemModel
|
||||
source?: FeedSourceInfo
|
||||
post: AppBskyFeedDefs.PostView
|
||||
record: AppBskyFeedPost.Record
|
||||
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
|
||||
moderation: PostModeration
|
||||
dataUpdatedAt: number
|
||||
isThreadChild?: boolean
|
||||
isThreadLastChild?: boolean
|
||||
isThreadParent?: boolean
|
||||
}) {
|
||||
const postShadowed = usePostShadow(post, dataUpdatedAt)
|
||||
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
|
||||
}
|
||||
|
||||
function FeedItemInner({
|
||||
post,
|
||||
record,
|
||||
reason,
|
||||
richText,
|
||||
moderation,
|
||||
isThreadChild,
|
||||
isThreadLastChild,
|
||||
isThreadParent,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
record: AppBskyFeedPost.Record
|
||||
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
|
||||
richText: RichTextAPI
|
||||
moderation: PostModeration
|
||||
isThreadChild?: boolean
|
||||
isThreadLastChild?: boolean
|
||||
isThreadParent?: boolean
|
||||
showReplyLine?: boolean
|
||||
}) {
|
||||
const store = useStores()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const pal = usePalette('default')
|
||||
const mutedThreads = useMutedThreads()
|
||||
const toggleThreadMute = useToggleThreadMute()
|
||||
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 ''
|
||||
|
@ -75,77 +121,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 || '',
|
||||
langPrefs.primaryLanguage,
|
||||
)
|
||||
|
||||
const onPressReply = React.useCallback(() => {
|
||||
track('FeedItem:PostReply')
|
||||
store.shell.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(() => {
|
||||
track('FeedItem:ThreadMute')
|
||||
try {
|
||||
const muted = toggleThreadMute(item.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})
|
||||
}
|
||||
}, [track, toggleThreadMute, 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, store])
|
||||
|
||||
const onPressShowMore = React.useCallback(() => {
|
||||
setLimitLines(false)
|
||||
|
@ -164,15 +155,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 />
|
||||
|
@ -194,10 +181,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}
|
||||
|
@ -209,17 +196,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"
|
||||
|
@ -241,10 +228,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>
|
||||
|
@ -256,10 +242,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
|
||||
|
@ -276,10 +262,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]}>
|
||||
|
@ -308,19 +294,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}
|
||||
|
@ -335,50 +318,22 @@ 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={mutedThreads.includes(item.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: {
|
||||
|
|
|
@ -1,24 +1,40 @@
|
|||
import React 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} from '@atproto/api'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import Svg, {Circle, Line} from 'react-native-svg'
|
||||
import {FeedItem} from './FeedItem'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {useStores} from '#/state'
|
||||
|
||||
export const FeedSlice = observer(function FeedSliceImpl({
|
||||
slice,
|
||||
dataUpdatedAt,
|
||||
ignoreFilterFor,
|
||||
}: {
|
||||
slice: PostsFeedSliceModel
|
||||
slice: FeedPostSlice
|
||||
dataUpdatedAt: number
|
||||
ignoreFilterFor?: string
|
||||
}) {
|
||||
if (slice.shouldFilter(ignoreFilterFor)) {
|
||||
return null
|
||||
const store = useStores()
|
||||
const moderations = React.useMemo(() => {
|
||||
return slice.items.map(item =>
|
||||
moderatePost(item.post, store.preferences.moderationOpts),
|
||||
)
|
||||
}, [slice, store.preferences.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 +43,34 @@ 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]}
|
||||
dataUpdatedAt={dataUpdatedAt}
|
||||
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]}
|
||||
dataUpdatedAt={dataUpdatedAt}
|
||||
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]}
|
||||
dataUpdatedAt={dataUpdatedAt}
|
||||
isThreadParent={isThreadParentAt(slice.items, last)}
|
||||
isThreadChild={isThreadChildAt(slice.items, last)}
|
||||
isThreadLastChild
|
||||
/>
|
||||
</>
|
||||
|
@ -55,12 +82,15 @@ 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]}
|
||||
dataUpdatedAt={dataUpdatedAt}
|
||||
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
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
@ -68,12 +98,12 @@ export const FeedSlice = observer(function FeedSliceImpl({
|
|||
)
|
||||
})
|
||||
|
||||
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 +145,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
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import {Pressable, View} from 'react-native'
|
|||
import {useStores} from 'state/index'
|
||||
import {navigate} from '../../../Navigation'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
|
||||
/**
|
||||
|
@ -15,6 +16,7 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'}
|
|||
|
||||
export function TestCtrls() {
|
||||
const store = useStores()
|
||||
const queryClient = useQueryClient()
|
||||
const {logout, login} = useSessionApi()
|
||||
const {openModal} = useModalControls()
|
||||
const onPressSignInAlice = async () => {
|
||||
|
@ -83,7 +85,7 @@ export function TestCtrls() {
|
|||
/>
|
||||
<Pressable
|
||||
testID="e2eRefreshHome"
|
||||
onPress={() => store.me.mainFeed.refresh()}
|
||||
onPress={() => queryClient.invalidateQueries({queryKey: ['post-feed']})}
|
||||
accessibilityRole="button"
|
||||
style={BTN}
|
||||
/>
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import React from 'react'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
|
||||
import {PostsFeedModel} from 'state/models/feeds/posts'
|
||||
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
|
||||
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
|
||||
|
@ -12,12 +11,9 @@ import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
|
|||
import {FeedsTabBar} from '../com/pager/FeedsTabBar'
|
||||
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {useStores} from 'state/index'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {FeedPage} from 'view/com/feeds/FeedPage'
|
||||
import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
|
||||
|
||||
export const POLL_FREQ = 30e3 // 30sec
|
||||
|
||||
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
|
||||
export const HomeScreen = withAuthRequired(
|
||||
observer(function HomeScreenImpl({}: Props) {
|
||||
|
@ -26,7 +22,7 @@ export const HomeScreen = withAuthRequired(
|
|||
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
|
||||
const pagerRef = React.useRef<PagerRef>(null)
|
||||
const [selectedPage, setSelectedPage] = React.useState(0)
|
||||
const [customFeeds, setCustomFeeds] = React.useState<PostsFeedModel[]>([])
|
||||
const [customFeeds, setCustomFeeds] = React.useState<FeedDescriptor[]>([])
|
||||
const [requestedCustomFeeds, setRequestedCustomFeeds] = React.useState<
|
||||
string[]
|
||||
>([])
|
||||
|
@ -39,14 +35,12 @@ export const HomeScreen = withAuthRequired(
|
|||
return
|
||||
}
|
||||
|
||||
const feeds = []
|
||||
const feeds: FeedDescriptor[] = []
|
||||
for (const uri of pinned) {
|
||||
if (uri.includes('app.bsky.feed.generator')) {
|
||||
const model = new PostsFeedModel(store, 'custom', {feed: uri})
|
||||
feeds.push(model)
|
||||
feeds.push(`feedgen|${uri}`)
|
||||
} else if (uri.includes('app.bsky.graph.list')) {
|
||||
const model = new PostsFeedModel(store, 'list', {list: uri})
|
||||
feeds.push(model)
|
||||
feeds.push(`list|${uri}`)
|
||||
}
|
||||
}
|
||||
pagerRef.current?.setPage(0)
|
||||
|
@ -62,6 +56,19 @@ export const HomeScreen = withAuthRequired(
|
|||
setRequestedCustomFeeds,
|
||||
])
|
||||
|
||||
const homeFeedParams = React.useMemo<FeedParams>(() => {
|
||||
if (!store.preferences.homeFeed.lab_mergeFeedEnabled) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
mergeFeedEnabled: true,
|
||||
mergeFeedSources: store.preferences.savedFeeds,
|
||||
}
|
||||
}, [
|
||||
store.preferences.homeFeed.lab_mergeFeedEnabled,
|
||||
store.preferences.savedFeeds,
|
||||
])
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
setMinimalShellMode(false)
|
||||
|
@ -129,14 +136,15 @@ export const HomeScreen = withAuthRequired(
|
|||
key="1"
|
||||
testID="followingFeedPage"
|
||||
isPageFocused={selectedPage === 0}
|
||||
feed={store.me.mainFeed}
|
||||
feed="home"
|
||||
feedParams={homeFeedParams}
|
||||
renderEmptyState={renderFollowingEmptyState}
|
||||
renderEndOfFeed={FollowingEndOfFeed}
|
||||
/>
|
||||
{customFeeds.map((f, index) => {
|
||||
return (
|
||||
<FeedPage
|
||||
key={f.reactKey}
|
||||
key={f}
|
||||
testID="customFeedPage"
|
||||
isPageFocused={selectedPage === 1 + index}
|
||||
feed={f}
|
||||
|
@ -148,17 +156,3 @@ export const HomeScreen = withAuthRequired(
|
|||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export function useHeaderOffset() {
|
||||
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||
const {fontScale} = useWindowDimensions()
|
||||
if (isDesktop) {
|
||||
return 0
|
||||
}
|
||||
if (isTablet) {
|
||||
return 50
|
||||
}
|
||||
// default text takes 44px, plus 34px of pad
|
||||
// scale the 44px by the font scale
|
||||
return 34 + 44 * fontScale
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ import {CenteredView} from '../com/util/Views'
|
|||
import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
|
||||
import {ProfileUiModel, Sections} from 'state/models/ui/profile'
|
||||
import {useStores} from 'state/index'
|
||||
import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
|
||||
import {ProfileHeader} from '../com/profile/ProfileHeader'
|
||||
import {FeedSlice} from '../com/posts/FeedSlice'
|
||||
import {ListCard} from 'view/com/lists/ListCard'
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from 'react-native'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {HeartIcon, HeartIconSolid} from 'lib/icons'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
|
@ -17,7 +18,7 @@ import {colors, s} from 'lib/styles'
|
|||
import {observer} from 'mobx-react-lite'
|
||||
import {useStores} from 'state/index'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
import {PostsFeedModel} from 'state/models/feeds/posts'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
|
||||
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
|
||||
|
@ -32,6 +33,7 @@ import {EmptyState} from 'view/com/util/EmptyState'
|
|||
import * as Toast from 'view/com/util/Toast'
|
||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {useCustomFeed} from 'lib/hooks/useCustomFeed'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||
|
@ -154,13 +156,6 @@ export const ProfileFeedScreenInner = observer(
|
|||
[rkey, feedOwnerDid],
|
||||
)
|
||||
const feedInfo = useCustomFeed(uri)
|
||||
const feed: PostsFeedModel = useMemo(() => {
|
||||
const model = new PostsFeedModel(store, 'custom', {
|
||||
feed: uri,
|
||||
})
|
||||
model.setup()
|
||||
return model
|
||||
}, [store, uri])
|
||||
const isPinned = store.preferences.isPinnedFeed(uri)
|
||||
useSetTitle(feedInfo?.displayName)
|
||||
|
||||
|
@ -352,7 +347,7 @@ export const ProfileFeedScreenInner = observer(
|
|||
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
|
||||
<FeedSection
|
||||
ref={feedSectionRef}
|
||||
feed={feed}
|
||||
feed={`feedgen|${uri}`}
|
||||
onScroll={onScroll}
|
||||
headerHeight={headerHeight}
|
||||
isScrolledDown={isScrolledDown}
|
||||
|
@ -395,7 +390,7 @@ export const ProfileFeedScreenInner = observer(
|
|||
)
|
||||
|
||||
interface FeedSectionProps {
|
||||
feed: PostsFeedModel
|
||||
feed: FeedDescriptor
|
||||
onScroll: OnScrollHandler
|
||||
headerHeight: number
|
||||
isScrolledDown: boolean
|
||||
|
@ -406,12 +401,14 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
|
|||
{feed, onScroll, headerHeight, isScrolledDown, scrollElRef},
|
||||
ref,
|
||||
) {
|
||||
const hasNew = feed.hasNewLatest && !feed.isRefreshing
|
||||
const [hasNew, setHasNew] = React.useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const onScrollToTop = useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({offset: -headerHeight})
|
||||
feed.refresh()
|
||||
}, [feed, scrollElRef, headerHeight])
|
||||
queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
|
||||
setHasNew(false)
|
||||
}, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: onScrollToTop,
|
||||
|
@ -425,7 +422,9 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
|
|||
<View>
|
||||
<Feed
|
||||
feed={feed}
|
||||
pollInterval={30e3}
|
||||
scrollElRef={scrollElRef}
|
||||
onHasNew={setHasNew}
|
||||
onScroll={onScroll}
|
||||
scrollEventThrottle={5}
|
||||
renderEmptyState={renderPostsEmpty}
|
||||
|
|
|
@ -12,6 +12,7 @@ import {useNavigation} from '@react-navigation/native'
|
|||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {RichText as RichTextAPI} from '@atproto/api'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
|
||||
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
|
||||
|
@ -28,11 +29,12 @@ import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
|
|||
import {FAB} from 'view/com/util/fab/FAB'
|
||||
import {Haptics} from 'lib/haptics'
|
||||
import {ListModel} from 'state/models/content/list'
|
||||
import {PostsFeedModel} from 'state/models/feeds/posts'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||
|
@ -109,34 +111,25 @@ export const ProfileListScreenInner = observer(
|
|||
}: Props & {listOwnerDid: string}) {
|
||||
const store = useStores()
|
||||
const {_} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {rkey} = route.params
|
||||
const listUri = `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`
|
||||
const feedSectionRef = React.useRef<SectionRef>(null)
|
||||
const aboutSectionRef = React.useRef<SectionRef>(null)
|
||||
const {openModal} = useModalControls()
|
||||
|
||||
const list: ListModel = useMemo(() => {
|
||||
const model = new ListModel(
|
||||
store,
|
||||
`at://${listOwnerDid}/app.bsky.graph.list/${rkey}`,
|
||||
)
|
||||
const model = new ListModel(store, listUri)
|
||||
return model
|
||||
}, [store, listOwnerDid, rkey])
|
||||
const feed = useMemo(
|
||||
() => new PostsFeedModel(store, 'list', {list: list.uri}),
|
||||
[store, list],
|
||||
)
|
||||
}, [store, listUri])
|
||||
useSetTitle(list.data?.name)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setMinimalShellMode(false)
|
||||
list.loadMore(true).then(() => {
|
||||
if (list.isCuratelist) {
|
||||
feed.setup()
|
||||
}
|
||||
})
|
||||
}, [setMinimalShellMode, list, feed]),
|
||||
list.loadMore(true)
|
||||
}, [setMinimalShellMode, list]),
|
||||
)
|
||||
|
||||
const onPressAddUser = useCallback(() => {
|
||||
|
@ -145,11 +138,13 @@ export const ProfileListScreenInner = observer(
|
|||
list,
|
||||
onAdd() {
|
||||
if (list.isCuratelist) {
|
||||
feed.refresh()
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: FEED_RQKEY(`list|${listUri}`),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [openModal, list, feed])
|
||||
}, [openModal, list, queryClient, listUri])
|
||||
|
||||
const onCurrentPageSelected = React.useCallback(
|
||||
(index: number) => {
|
||||
|
@ -178,10 +173,10 @@ export const ProfileListScreenInner = observer(
|
|||
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
|
||||
<FeedSection
|
||||
ref={feedSectionRef}
|
||||
feed={`list|${listUri}`}
|
||||
scrollElRef={
|
||||
scrollElRef as React.MutableRefObject<FlatList<any> | null>
|
||||
}
|
||||
feed={feed}
|
||||
onScroll={onScroll}
|
||||
headerHeight={headerHeight}
|
||||
isScrolledDown={isScrolledDown}
|
||||
|
@ -562,7 +557,7 @@ const Header = observer(function HeaderImpl({
|
|||
})
|
||||
|
||||
interface FeedSectionProps {
|
||||
feed: PostsFeedModel
|
||||
feed: FeedDescriptor
|
||||
onScroll: OnScrollHandler
|
||||
headerHeight: number
|
||||
isScrolledDown: boolean
|
||||
|
@ -573,12 +568,14 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
|
|||
{feed, scrollElRef, onScroll, headerHeight, isScrolledDown},
|
||||
ref,
|
||||
) {
|
||||
const hasNew = feed.hasNewLatest && !feed.isRefreshing
|
||||
const queryClient = useQueryClient()
|
||||
const [hasNew, setHasNew] = React.useState(false)
|
||||
|
||||
const onScrollToTop = useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({offset: -headerHeight})
|
||||
feed.refresh()
|
||||
}, [feed, scrollElRef, headerHeight])
|
||||
queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
|
||||
setHasNew(false)
|
||||
}, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: onScrollToTop,
|
||||
}))
|
||||
|
@ -592,7 +589,9 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
|
|||
<Feed
|
||||
testID="listFeed"
|
||||
feed={feed}
|
||||
pollInterval={30e3}
|
||||
scrollElRef={scrollElRef}
|
||||
onHasNew={setHasNew}
|
||||
onScroll={onScroll}
|
||||
scrollEventThrottle={1}
|
||||
renderEmptyState={renderPostsEmpty}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue