about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-rw-r--r--src/state/cache/post-shadow.ts90
-rw-r--r--src/state/queries/post-thread.ts177
-rw-r--r--src/state/queries/post.ts156
-rw-r--r--src/state/queries/resolve-uri.ts17
-rw-r--r--src/state/session/index.tsx2
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],