about summary refs log tree commit diff
path: root/src/state/queries/post-thread.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/queries/post-thread.ts')
-rw-r--r--src/state/queries/post-thread.ts307
1 files changed, 307 insertions, 0 deletions
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
new file mode 100644
index 000000000..cde45723a
--- /dev/null
+++ b/src/state/queries/post-thread.ts
@@ -0,0 +1,307 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AppBskyFeedGetPostThread,
+} from '@atproto/api'
+import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
+
+import {getAgent} from '#/state/session'
+import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
+import {findPostInQueryData as findPostInFeedQueryData} from './post-feed'
+import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed'
+import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri'
+
+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
+  isParentLoading?: boolean
+  isChildLoading?: 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 queryClient = useQueryClient()
+  return useQuery<ThreadNode, Error>({
+    queryKey: RQKEY(uri || ''),
+    async queryFn() {
+      const res = await getAgent().getPostThread({uri: uri!})
+      if (res.success) {
+        const nodes = responseToThreadNodes(res.data.thread)
+        precacheResolvedUris(queryClient, nodes) // precache the handle->did resolution
+        return nodes
+      }
+      return {type: 'unknown', uri: uri!}
+    },
+    enabled: !!uri,
+    placeholderData: () => {
+      if (!uri) {
+        return undefined
+      }
+      {
+        const item = findPostInQueryData(queryClient, uri)
+        if (item) {
+          return threadNodeToPlaceholderThread(item)
+        }
+      }
+      {
+        const item = findPostInFeedQueryData(queryClient, uri)
+        if (item) {
+          return postViewToPlaceholderThread(item)
+        }
+      }
+      {
+        const item = findPostInNotifsQueryData(queryClient, uri)
+        if (item) {
+          return postViewToPlaceholderThread(item)
+        }
+      }
+      return undefined
+    },
+  })
+}
+
+export function sortThread(
+  node: ThreadNode,
+  opts: UsePreferencesQueryResponse['threadViewPrefs'],
+): 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'))
+              // do not show blocked posts in replies
+              .filter(node => node.type !== 'blocked')
+          : 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: ''}
+  }
+}
+
+function findPostInQueryData(
+  queryClient: QueryClient,
+  uri: string,
+): ThreadNode | undefined {
+  const generator = findAllPostsInQueryData(queryClient, uri)
+  const result = generator.next()
+  if (result.done) {
+    return undefined
+  } else {
+    return result.value
+  }
+}
+
+export function* findAllPostsInQueryData(
+  queryClient: QueryClient,
+  uri: string,
+): Generator<ThreadNode, void> {
+  const queryDatas = queryClient.getQueriesData<ThreadNode>({
+    queryKey: ['post-thread'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData) {
+      continue
+    }
+    for (const item of traverseThread(queryData)) {
+      if (item.uri === uri) {
+        yield item
+      }
+    }
+  }
+}
+
+function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
+  if (node.type === 'post') {
+    if (node.parent) {
+      yield* traverseThread(node.parent)
+    }
+    yield node
+    if (node.replies?.length) {
+      for (const reply of node.replies) {
+        yield* traverseThread(reply)
+      }
+    }
+  }
+}
+
+function threadNodeToPlaceholderThread(
+  node: ThreadNode,
+): ThreadNode | undefined {
+  if (node.type !== 'post') {
+    return undefined
+  }
+  return {
+    type: node.type,
+    _reactKey: node._reactKey,
+    uri: node.uri,
+    post: node.post,
+    record: node.record,
+    parent: undefined,
+    replies: undefined,
+    viewer: node.viewer,
+    ctx: {
+      depth: 0,
+      isHighlightedPost: true,
+      hasMore: false,
+      showChildReplyLine: false,
+      showParentReplyLine: false,
+      isParentLoading: !!node.record.reply,
+      isChildLoading: !!node.post.replyCount,
+    },
+  }
+}
+
+function postViewToPlaceholderThread(
+  post: AppBskyFeedDefs.PostView,
+): ThreadNode {
+  return {
+    type: 'post',
+    _reactKey: post.uri,
+    uri: post.uri,
+    post: post,
+    record: post.record as AppBskyFeedPost.Record, // validated in notifs
+    parent: undefined,
+    replies: undefined,
+    viewer: post.viewer,
+    ctx: {
+      depth: 0,
+      isHighlightedPost: true,
+      hasMore: false,
+      showChildReplyLine: false,
+      showParentReplyLine: false,
+      isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply,
+      isChildLoading: !!post.replyCount,
+    },
+  }
+}