diff options
Diffstat (limited to 'src/state')
-rw-r--r-- | src/state/cache/post-shadow.ts | 90 | ||||
-rw-r--r-- | src/state/queries/post-thread.ts | 177 | ||||
-rw-r--r-- | src/state/queries/post.ts | 156 | ||||
-rw-r--r-- | src/state/queries/resolve-uri.ts | 17 | ||||
-rw-r--r-- | src/state/session/index.tsx | 2 |
5 files changed, 442 insertions, 0 deletions
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts new file mode 100644 index 000000000..c06ed60c4 --- /dev/null +++ b/src/state/cache/post-shadow.ts @@ -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, + }, + } +} diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts new file mode 100644 index 000000000..4dea8aaf1 --- /dev/null +++ b/src/state/queries/post-thread.ts @@ -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: ''} + } +} diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts new file mode 100644 index 000000000..f62190c67 --- /dev/null +++ b/src/state/queries/post.ts @@ -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}) + }, + }, + ) +} diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts new file mode 100644 index 000000000..770be5cf8 --- /dev/null +++ b/src/state/queries/resolve-uri.ts @@ -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() + }) +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 0f3118168..8e1f9c1a1 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -186,6 +186,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }), ) + setState(s => ({...s, agent})) upsertAccount(account) logger.debug(`session: logged in`, { @@ -238,6 +239,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }), ) + setState(s => ({...s, agent})) upsertAccount(account) }, [upsertAccount], |