-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
12 changed files
with
1,385 additions
and
475 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
import {useEffect, useState, useCallback, useRef} from 'react' | ||
import EventEmitter from 'eventemitter3' | ||
import {AppBskyFeedDefs} from '@atproto/api' | ||
|
||
const emitter = new EventEmitter() | ||
|
||
export interface PostShadow { | ||
likeUri: string | undefined | ||
likeCount: number | undefined | ||
repostUri: string | undefined | ||
repostCount: number | undefined | ||
isDeleted: boolean | ||
} | ||
|
||
export const POST_TOMBSTONE = Symbol('PostTombstone') | ||
|
||
interface CacheEntry { | ||
ts: number | ||
value: PostShadow | ||
} | ||
|
||
export function usePostShadow( | ||
post: AppBskyFeedDefs.PostView, | ||
ifAfterTS: number, | ||
): AppBskyFeedDefs.PostView | typeof POST_TOMBSTONE { | ||
const [state, setState] = useState<CacheEntry>({ | ||
ts: Date.now(), | ||
value: fromPost(post), | ||
}) | ||
const firstRun = useRef(true) | ||
|
||
const onUpdate = useCallback( | ||
(value: Partial<PostShadow>) => { | ||
setState(s => ({ts: Date.now(), value: {...s.value, ...value}})) | ||
}, | ||
[setState], | ||
) | ||
|
||
// react to shadow updates | ||
useEffect(() => { | ||
emitter.addListener(post.uri, onUpdate) | ||
return () => { | ||
emitter.removeListener(post.uri, onUpdate) | ||
} | ||
}, [post.uri, onUpdate]) | ||
|
||
// react to post updates | ||
useEffect(() => { | ||
// dont fire on first run to avoid needless re-renders | ||
if (!firstRun.current) { | ||
setState({ts: Date.now(), value: fromPost(post)}) | ||
} | ||
firstRun.current = false | ||
}, [post]) | ||
|
||
return state.ts > ifAfterTS ? mergeShadow(post, state.value) : post | ||
} | ||
|
||
export function updatePostShadow(uri: string, value: Partial<PostShadow>) { | ||
emitter.emit(uri, value) | ||
} | ||
|
||
function fromPost(post: AppBskyFeedDefs.PostView): PostShadow { | ||
return { | ||
likeUri: post.viewer?.like, | ||
likeCount: post.likeCount, | ||
repostUri: post.viewer?.repost, | ||
repostCount: post.repostCount, | ||
isDeleted: false, | ||
} | ||
} | ||
|
||
function mergeShadow( | ||
post: AppBskyFeedDefs.PostView, | ||
shadow: PostShadow, | ||
): AppBskyFeedDefs.PostView | typeof POST_TOMBSTONE { | ||
if (shadow.isDeleted) { | ||
return POST_TOMBSTONE | ||
} | ||
return { | ||
...post, | ||
likeCount: shadow.likeCount, | ||
repostCount: shadow.repostCount, | ||
viewer: { | ||
...(post.viewer || {}), | ||
like: shadow.likeUri, | ||
repost: shadow.repostUri, | ||
}, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: ''} | ||
} | ||
} |
Oops, something went wrong.