Refactor post threads to use react query (#1851)
* Add post and post-thread queries * Update PostThread components to use new queries * Move from normalized cache to shadow cache model * Merge post shadow into the post automatically * Remove dead code * Remove old temporary session * Fix: set agent on session creation * Temporarily double-login * Handle post-thread uri resolution errors
This commit is contained in:
parent
625cbc435f
commit
fb4f5709c4
12 changed files with 1386 additions and 476 deletions
177
src/state/queries/post-thread.ts
Normal file
177
src/state/queries/post-thread.ts
Normal file
|
@ -0,0 +1,177 @@
|
|||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedGetPostThread,
|
||||
} from '@atproto/api'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {useSession} from '../session'
|
||||
import {ThreadViewPreference} from '../models/ui/preferences'
|
||||
|
||||
export const RQKEY = (uri: string) => ['post-thread', uri]
|
||||
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
|
||||
|
||||
export interface ThreadCtx {
|
||||
depth: number
|
||||
isHighlightedPost?: boolean
|
||||
hasMore?: boolean
|
||||
showChildReplyLine?: boolean
|
||||
showParentReplyLine?: boolean
|
||||
}
|
||||
|
||||
export type ThreadPost = {
|
||||
type: 'post'
|
||||
_reactKey: string
|
||||
uri: string
|
||||
post: AppBskyFeedDefs.PostView
|
||||
record: AppBskyFeedPost.Record
|
||||
parent?: ThreadNode
|
||||
replies?: ThreadNode[]
|
||||
viewer?: AppBskyFeedDefs.ViewerThreadState
|
||||
ctx: ThreadCtx
|
||||
}
|
||||
|
||||
export type ThreadNotFound = {
|
||||
type: 'not-found'
|
||||
_reactKey: string
|
||||
uri: string
|
||||
ctx: ThreadCtx
|
||||
}
|
||||
|
||||
export type ThreadBlocked = {
|
||||
type: 'blocked'
|
||||
_reactKey: string
|
||||
uri: string
|
||||
ctx: ThreadCtx
|
||||
}
|
||||
|
||||
export type ThreadUnknown = {
|
||||
type: 'unknown'
|
||||
uri: string
|
||||
}
|
||||
|
||||
export type ThreadNode =
|
||||
| ThreadPost
|
||||
| ThreadNotFound
|
||||
| ThreadBlocked
|
||||
| ThreadUnknown
|
||||
|
||||
export function usePostThreadQuery(uri: string | undefined) {
|
||||
const {agent} = useSession()
|
||||
return useQuery<ThreadNode, Error>(
|
||||
RQKEY(uri || ''),
|
||||
async () => {
|
||||
const res = await agent.getPostThread({uri: uri!})
|
||||
if (res.success) {
|
||||
return responseToThreadNodes(res.data.thread)
|
||||
}
|
||||
return {type: 'unknown', uri: uri!}
|
||||
},
|
||||
{enabled: !!uri},
|
||||
)
|
||||
}
|
||||
|
||||
export function sortThread(
|
||||
node: ThreadNode,
|
||||
opts: ThreadViewPreference,
|
||||
): ThreadNode {
|
||||
if (node.type !== 'post') {
|
||||
return node
|
||||
}
|
||||
if (node.replies) {
|
||||
node.replies.sort((a: ThreadNode, b: ThreadNode) => {
|
||||
if (a.type !== 'post') {
|
||||
return 1
|
||||
}
|
||||
if (b.type !== 'post') {
|
||||
return -1
|
||||
}
|
||||
|
||||
const aIsByOp = a.post.author.did === node.post?.author.did
|
||||
const bIsByOp = b.post.author.did === node.post?.author.did
|
||||
if (aIsByOp && bIsByOp) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsByOp) {
|
||||
return -1 // op's own reply
|
||||
} else if (bIsByOp) {
|
||||
return 1 // op's own reply
|
||||
}
|
||||
if (opts.prioritizeFollowedUsers) {
|
||||
const af = a.post.author.viewer?.following
|
||||
const bf = b.post.author.viewer?.following
|
||||
if (af && !bf) {
|
||||
return -1
|
||||
} else if (!af && bf) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if (opts.sort === 'oldest') {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt)
|
||||
} else if (opts.sort === 'newest') {
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
||||
} else if (opts.sort === 'most-likes') {
|
||||
if (a.post.likeCount === b.post.likeCount) {
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
|
||||
} else {
|
||||
return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
|
||||
}
|
||||
} else if (opts.sort === 'random') {
|
||||
return 0.5 - Math.random() // this is vaguely criminal but we can get away with it
|
||||
}
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
||||
})
|
||||
node.replies.forEach(reply => sortThread(reply, opts))
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// internal methods
|
||||
// =
|
||||
|
||||
function responseToThreadNodes(
|
||||
node: ThreadViewNode,
|
||||
depth = 0,
|
||||
direction: 'up' | 'down' | 'start' = 'start',
|
||||
): ThreadNode {
|
||||
if (
|
||||
AppBskyFeedDefs.isThreadViewPost(node) &&
|
||||
AppBskyFeedPost.isRecord(node.post.record) &&
|
||||
AppBskyFeedPost.validateRecord(node.post.record).success
|
||||
) {
|
||||
return {
|
||||
type: 'post',
|
||||
_reactKey: node.post.uri,
|
||||
uri: node.post.uri,
|
||||
post: node.post,
|
||||
record: node.post.record,
|
||||
parent:
|
||||
node.parent && direction !== 'down'
|
||||
? responseToThreadNodes(node.parent, depth - 1, 'up')
|
||||
: undefined,
|
||||
replies:
|
||||
node.replies?.length && direction !== 'up'
|
||||
? node.replies.map(reply =>
|
||||
responseToThreadNodes(reply, depth + 1, 'down'),
|
||||
)
|
||||
: undefined,
|
||||
viewer: node.viewer,
|
||||
ctx: {
|
||||
depth,
|
||||
isHighlightedPost: depth === 0,
|
||||
hasMore:
|
||||
direction === 'down' && !node.replies?.length && !!node.replyCount,
|
||||
showChildReplyLine:
|
||||
direction === 'up' ||
|
||||
(direction === 'down' && !!node.replies?.length),
|
||||
showParentReplyLine:
|
||||
(direction === 'up' && !!node.parent) ||
|
||||
(direction === 'down' && depth !== 1),
|
||||
},
|
||||
}
|
||||
} else if (AppBskyFeedDefs.isBlockedPost(node)) {
|
||||
return {type: 'blocked', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
|
||||
} else if (AppBskyFeedDefs.isNotFoundPost(node)) {
|
||||
return {type: 'not-found', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
|
||||
} else {
|
||||
return {type: 'unknown', uri: ''}
|
||||
}
|
||||
}
|
156
src/state/queries/post.ts
Normal file
156
src/state/queries/post.ts
Normal file
|
@ -0,0 +1,156 @@
|
|||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {useQuery, useMutation} from '@tanstack/react-query'
|
||||
import {useSession} from '../session'
|
||||
import {updatePostShadow} from '../cache/post-shadow'
|
||||
|
||||
export const RQKEY = (postUri: string) => ['post', postUri]
|
||||
|
||||
export function usePostQuery(uri: string | undefined) {
|
||||
const {agent} = useSession()
|
||||
return useQuery<AppBskyFeedDefs.PostView>(
|
||||
RQKEY(uri || ''),
|
||||
async () => {
|
||||
const res = await agent.getPosts({uris: [uri!]})
|
||||
if (res.success && res.data.posts[0]) {
|
||||
return res.data.posts[0]
|
||||
}
|
||||
|
||||
throw new Error('No data')
|
||||
},
|
||||
{
|
||||
enabled: !!uri,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function usePostLikeMutation() {
|
||||
const {agent} = useSession()
|
||||
return useMutation<
|
||||
{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), {
|
||||
onMutate(variables) {
|
||||
// optimistically update the post-shadow
|
||||
updatePostShadow(variables.uri, {
|
||||
likeCount: variables.likeCount + 1,
|
||||
likeUri: 'pending',
|
||||
})
|
||||
},
|
||||
onSuccess(data, variables) {
|
||||
// finalize the post-shadow with the like URI
|
||||
updatePostShadow(variables.uri, {
|
||||
likeUri: data.uri,
|
||||
})
|
||||
},
|
||||
onError(error, variables) {
|
||||
// revert the optimistic update
|
||||
updatePostShadow(variables.uri, {
|
||||
likeCount: variables.likeCount,
|
||||
likeUri: undefined,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePostUnlikeMutation() {
|
||||
const {agent} = useSession()
|
||||
return useMutation<
|
||||
void,
|
||||
Error,
|
||||
{postUri: string; likeUri: string; likeCount: number}
|
||||
>(
|
||||
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,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function usePostRepostMutation() {
|
||||
const {agent} = useSession()
|
||||
return useMutation<
|
||||
{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), {
|
||||
onMutate(variables) {
|
||||
// optimistically update the post-shadow
|
||||
updatePostShadow(variables.uri, {
|
||||
repostCount: variables.repostCount + 1,
|
||||
repostUri: 'pending',
|
||||
})
|
||||
},
|
||||
onSuccess(data, variables) {
|
||||
// finalize the post-shadow with the repost URI
|
||||
updatePostShadow(variables.uri, {
|
||||
repostUri: data.uri,
|
||||
})
|
||||
},
|
||||
onError(error, variables) {
|
||||
// revert the optimistic update
|
||||
updatePostShadow(variables.uri, {
|
||||
repostCount: variables.repostCount,
|
||||
repostUri: undefined,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePostUnrepostMutation() {
|
||||
const {agent} = useSession()
|
||||
return useMutation<
|
||||
void,
|
||||
Error,
|
||||
{postUri: string; repostUri: string; repostCount: number}
|
||||
>(
|
||||
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,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function usePostDeleteMutation() {
|
||||
const {agent} = useSession()
|
||||
return useMutation<void, Error, {uri: string}>(
|
||||
async ({uri}) => {
|
||||
await agent.deletePost(uri)
|
||||
},
|
||||
{
|
||||
onSuccess(data, variables) {
|
||||
updatePostShadow(variables.uri, {isDeleted: true})
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
17
src/state/queries/resolve-uri.ts
Normal file
17
src/state/queries/resolve-uri.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import {useQuery} from '@tanstack/react-query'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {useSession} from '../session'
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue