Skip to content

Commit

Permalink
Refactor post threads to use react query (#1851)
Browse files Browse the repository at this point in the history
* 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
pfrazee authored Nov 9, 2023
1 parent 625cbc4 commit fb4f570
Show file tree
Hide file tree
Showing 12 changed files with 1,385 additions and 475 deletions.
2 changes: 1 addition & 1 deletion src/App.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const InnerApp = observer(function AppImpl() {
analytics.init(store)
})
dynamicActivate(defaultLocale) // async import of locale data
}, [resumeSession])
}, [])

useEffect(() => {
const account = persisted.get('session').currentAccount
Expand Down
90 changes: 90 additions & 0 deletions src/state/cache/post-shadow.ts
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,
},
}
}
177 changes: 177 additions & 0 deletions src/state/queries/post-thread.ts
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: ''}
}
}
Loading

0 comments on commit fb4f570

Please sign in to comment.