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:
Paul Frazee 2023-11-10 15:34:25 -08:00 committed by GitHub
parent 51f04b9620
commit c8c308e31e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 904 additions and 1081 deletions

View file

@ -0,0 +1,176 @@
import {useCallback, useMemo} from 'react'
import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {useSession} from '../session'
import {useFeedTuners} from '../preferences/feed-tuners'
import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip'
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
import {FollowingFeedAPI} from 'lib/api/feed/following'
import {AuthorFeedAPI} from 'lib/api/feed/author'
import {LikesFeedAPI} from 'lib/api/feed/likes'
import {CustomFeedAPI} from 'lib/api/feed/custom'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
import {useStores} from '../models/root-store'
type ActorDid = string
type AuthorFilter =
| 'posts_with_replies'
| 'posts_no_replies'
| 'posts_with_media'
type FeedUri = string
type ListUri = string
export type FeedDescriptor =
| 'home'
| 'following'
| `author|${ActorDid}|${AuthorFilter}`
| `feedgen|${FeedUri}`
| `likes|${ActorDid}`
| `list|${ListUri}`
export interface FeedParams {
disableTuner?: boolean
mergeFeedEnabled?: boolean
mergeFeedSources?: string[]
}
type RQPageParam = string | undefined
export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
return ['post-feed', feedDesc, params || {}]
}
export interface FeedPostSliceItem {
_reactKey: string
uri: string
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
}
export interface FeedPostSlice {
_reactKey: string
rootUri: string
isThread: boolean
items: FeedPostSliceItem[]
}
export interface FeedPage {
cursor: string | undefined
slices: FeedPostSlice[]
}
export function usePostFeedQuery(
feedDesc: FeedDescriptor,
params?: FeedParams,
opts?: {enabled?: boolean},
) {
const {agent} = useSession()
const feedTuners = useFeedTuners(feedDesc)
const store = useStores()
const enabled = opts?.enabled !== false
const api: FeedAPI = useMemo(() => {
if (feedDesc === 'home') {
return new MergeFeedAPI(agent, params || {}, feedTuners)
} else if (feedDesc === 'following') {
return new FollowingFeedAPI(agent)
} else if (feedDesc.startsWith('author')) {
const [_, actor, filter] = feedDesc.split('|')
return new AuthorFeedAPI(agent, {actor, filter})
} else if (feedDesc.startsWith('likes')) {
const [_, actor] = feedDesc.split('|')
return new LikesFeedAPI(agent, {actor})
} else if (feedDesc.startsWith('feedgen')) {
const [_, feed] = feedDesc.split('|')
return new CustomFeedAPI(agent, {feed})
} else if (feedDesc.startsWith('list')) {
const [_, list] = feedDesc.split('|')
return new ListFeedAPI(agent, {list})
} else {
// shouldnt happen
return new FollowingFeedAPI(agent)
}
}, [feedDesc, params, feedTuners, agent])
const tuner = useMemo(
() => (params?.disableTuner ? new NoopFeedTuner() : new FeedTuner()),
[params],
)
const pollLatest = useCallback(async () => {
if (!enabled) {
return false
}
console.log('poll')
const post = await api.peekLatest()
if (post) {
const slices = tuner.tune([post], feedTuners, {
dryRun: true,
maintainOrder: true,
})
if (slices[0]) {
if (
!moderatePost(
slices[0].items[0].post,
store.preferences.moderationOpts,
).content.filter
) {
return true
}
}
}
return false
}, [api, tuner, feedTuners, store.preferences.moderationOpts, enabled])
const out = useInfiniteQuery<
FeedPage,
Error,
InfiniteData<FeedPage>,
QueryKey,
RQPageParam
>({
queryKey: RQKEY(feedDesc, params),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
console.log('fetch', feedDesc, pageParam)
if (!pageParam) {
tuner.reset()
}
const res = await api.fetch({cursor: pageParam, limit: 30})
const slices = tuner.tune(res.feed, feedTuners)
return {
cursor: res.cursor,
slices: slices.map(slice => ({
_reactKey: slice._reactKey,
rootUri: slice.rootItem.post.uri,
isThread:
slice.items.length > 1 &&
slice.items.every(
item => item.post.author.did === slice.items[0].post.author.did,
),
source: undefined, // TODO
items: slice.items
.map((item, i) => {
if (
AppBskyFeedPost.isRecord(item.post.record) &&
AppBskyFeedPost.validateRecord(item.post.record).success
) {
return {
_reactKey: `${slice._reactKey}-${i}`,
uri: item.post.uri,
post: item.post,
record: item.post.record,
reason: i === 0 && slice.source ? slice.source : item.reason,
}
}
return undefined
})
.filter(Boolean) as FeedPostSliceItem[],
})),
}
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
enabled,
})
return {...out, pollLatest}
}

View file

@ -57,17 +57,17 @@ export type ThreadNode =
export function usePostThreadQuery(uri: string | undefined) {
const {agent} = useSession()
return useQuery<ThreadNode, Error>(
RQKEY(uri || ''),
async () => {
return useQuery<ThreadNode, Error>({
queryKey: RQKEY(uri || ''),
async queryFn() {
const res = await agent.getPostThread({uri: uri!})
if (res.success) {
return responseToThreadNodes(res.data.thread)
}
return {type: 'unknown', uri: uri!}
},
{enabled: !!uri},
)
enabled: !!uri,
})
}
export function sortThread(

View file

@ -7,9 +7,9 @@ export const RQKEY = (postUri: string) => ['post', postUri]
export function usePostQuery(uri: string | undefined) {
const {agent} = useSession()
return useQuery<AppBskyFeedDefs.PostView>(
RQKEY(uri || ''),
async () => {
return useQuery<AppBskyFeedDefs.PostView>({
queryKey: RQKEY(uri || ''),
async queryFn() {
const res = await agent.getPosts({uris: [uri!]})
if (res.success && res.data.posts[0]) {
return res.data.posts[0]
@ -17,10 +17,8 @@ export function usePostQuery(uri: string | undefined) {
throw new Error('No data')
},
{
enabled: !!uri,
},
)
enabled: !!uri,
})
}
export function usePostLikeMutation() {
@ -29,7 +27,8 @@ export function usePostLikeMutation() {
{uri: string}, // responds with the uri of the like
Error,
{uri: string; cid: string; likeCount: number} // the post's uri, cid, and likes
>(post => agent.like(post.uri, post.cid), {
>({
mutationFn: post => agent.like(post.uri, post.cid),
onMutate(variables) {
// optimistically update the post-shadow
updatePostShadow(variables.uri, {
@ -59,27 +58,25 @@ export function usePostUnlikeMutation() {
void,
Error,
{postUri: string; likeUri: string; likeCount: number}
>(
async ({likeUri}) => {
>({
mutationFn: async ({likeUri}) => {
await agent.deleteLike(likeUri)
},
{
onMutate(variables) {
// optimistically update the post-shadow
updatePostShadow(variables.postUri, {
likeCount: variables.likeCount - 1,
likeUri: undefined,
})
},
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.postUri, {
likeCount: variables.likeCount,
likeUri: variables.likeUri,
})
},
onMutate(variables) {
// optimistically update the post-shadow
updatePostShadow(variables.postUri, {
likeCount: variables.likeCount - 1,
likeUri: undefined,
})
},
)
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.postUri, {
likeCount: variables.likeCount,
likeUri: variables.likeUri,
})
},
})
}
export function usePostRepostMutation() {
@ -88,7 +85,8 @@ export function usePostRepostMutation() {
{uri: string}, // responds with the uri of the repost
Error,
{uri: string; cid: string; repostCount: number} // the post's uri, cid, and reposts
>(post => agent.repost(post.uri, post.cid), {
>({
mutationFn: post => agent.repost(post.uri, post.cid),
onMutate(variables) {
// optimistically update the post-shadow
updatePostShadow(variables.uri, {
@ -118,39 +116,35 @@ export function usePostUnrepostMutation() {
void,
Error,
{postUri: string; repostUri: string; repostCount: number}
>(
async ({repostUri}) => {
>({
mutationFn: async ({repostUri}) => {
await agent.deleteRepost(repostUri)
},
{
onMutate(variables) {
// optimistically update the post-shadow
updatePostShadow(variables.postUri, {
repostCount: variables.repostCount - 1,
repostUri: undefined,
})
},
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.postUri, {
repostCount: variables.repostCount,
repostUri: variables.repostUri,
})
},
onMutate(variables) {
// optimistically update the post-shadow
updatePostShadow(variables.postUri, {
repostCount: variables.repostCount - 1,
repostUri: undefined,
})
},
)
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.postUri, {
repostCount: variables.repostCount,
repostUri: variables.repostUri,
})
},
})
}
export function usePostDeleteMutation() {
const {agent} = useSession()
return useMutation<void, Error, {uri: string}>(
async ({uri}) => {
return useMutation<void, Error, {uri: string}>({
mutationFn: async ({uri}) => {
await agent.deletePost(uri)
},
{
onSuccess(data, variables) {
updatePostShadow(variables.uri, {isDeleted: true})
},
onSuccess(data, variables) {
updatePostShadow(variables.uri, {isDeleted: true})
},
)
})
}

View file

@ -6,12 +6,15 @@ export const RQKEY = (uri: string) => ['resolved-uri', uri]
export function useResolveUriQuery(uri: string) {
const {agent} = useSession()
return useQuery<string | undefined, Error>(RQKEY(uri), async () => {
const urip = new AtUri(uri)
if (!urip.host.startsWith('did:')) {
const res = await agent.resolveHandle({handle: urip.host})
urip.host = res.data.did
}
return urip.toString()
return useQuery<string | undefined, Error>({
queryKey: RQKEY(uri),
async queryFn() {
const urip = new AtUri(uri)
if (!urip.host.startsWith('did:')) {
const res = await agent.resolveHandle({handle: urip.host})
urip.host = res.data.did
}
return urip.toString()
},
})
}