From 5f586cbec02a259edff9d798b35988eca30f9c81 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Sat, 17 May 2025 13:40:14 -0700 Subject: [PATCH] Re-org --- src/state/queries/useGetPostThreadV2.ts | 774 ------------------ src/state/queries/usePostThread/index.ts | 93 +++ src/state/queries/usePostThread/queryCache.ts | 106 +++ src/state/queries/usePostThread/traversal.ts | 289 +++++++ src/state/queries/usePostThread/types.ts | 74 ++ src/state/queries/usePostThread/utils.ts | 34 + src/state/queries/usePostThread/views.ts | 113 +++ src/view/screens/PostThread.tsx | 154 ++-- 8 files changed, 794 insertions(+), 843 deletions(-) delete mode 100644 src/state/queries/useGetPostThreadV2.ts create mode 100644 src/state/queries/usePostThread/index.ts create mode 100644 src/state/queries/usePostThread/queryCache.ts create mode 100644 src/state/queries/usePostThread/traversal.ts create mode 100644 src/state/queries/usePostThread/types.ts create mode 100644 src/state/queries/usePostThread/utils.ts create mode 100644 src/state/queries/usePostThread/views.ts diff --git a/src/state/queries/useGetPostThreadV2.ts b/src/state/queries/useGetPostThreadV2.ts deleted file mode 100644 index b41975e8bc..0000000000 --- a/src/state/queries/useGetPostThreadV2.ts +++ /dev/null @@ -1,774 +0,0 @@ -import {useCallback, useMemo, useState} from 'react' -import { - $Typed, - AtUri, - AppBskyFeedDefs, - AppBskyUnspeccedDefs, - AppBskyFeedThreadgate, - AppBskyUnspeccedGetPostThreadV2, - ModerationOpts, - BskyThreadViewPreference, - moderatePost, - ModerationDecision, - AppBskyEmbedRecord, - AppBskyFeedPost, - APP_BSKY_UNSPECCED, -} from '@atproto/api' -import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query' - -import {useAgent, useSession} from '#/state/session' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' -import * as bsky from '#/types/bsky' -import { - didOrHandleUriMatches, - embedViewRecordToPostView, - getEmbeddedPost, -} from './util' -import { - findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData, - findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData, -} from '#/state/queries/explore-feed-previews' -import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' -import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' -import { - findAllPostsInQueryData as findAllPostsInSearchQueryData, - findAllProfilesInQueryData as findAllProfilesInSearchQueryData, -} from '#/state/queries/search-posts' -import { - findAllPostsInQueryData as findAllPostsInNotifsQueryData, - findAllProfilesInQueryData as findAllProfilesInNotifsQueryData, -} from './notifications/feed' -import { - findAllPostsInQueryData as findAllPostsInFeedQueryData, - findAllProfilesInQueryData as findAllProfilesInFeedQueryData, -} from './post-feed' - -export type PostThreadV2Params = { - view: 'tree' | 'linear' - sort: 'hotness' | 'oldest' | 'newest' | 'most-likes' | 'random' | string - prioritizeFollows: BskyThreadViewPreference['prioritizeFollowedUsers'] -} - -export const getPostThreadV2QueryKeyRoot = 'getPostThreadV2' as const -export const createGetPostThreadV2QueryKey = ( - props: Pick, -) => [getPostThreadV2QueryKeyRoot, props] as const - -export type GetPostThreadV2QueryProps = { - uri?: string - enabled?: boolean - params: PostThreadV2Params - state: { - shownHiddenReplyKinds: Set - } -} - -export type GetPostThreadV2QueryData = { - slices: Slice[] - threadgate?: AppBskyFeedDefs.ThreadgateView -} - -export function mapSortOptionsToSortID(sort: PostThreadV2Params['sort']) { - switch (sort) { - case 'hotness': - return APP_BSKY_UNSPECCED.GetPostThreadV2Hotness - case 'oldest': - return APP_BSKY_UNSPECCED.GetPostThreadV2Oldest - case 'newest': - return APP_BSKY_UNSPECCED.GetPostThreadV2Newest - case 'most-likes': - return APP_BSKY_UNSPECCED.GetPostThreadV2MostLikes - default: - return APP_BSKY_UNSPECCED.GetPostThreadV2Hotness - } -} - -export function useGetPostThreadV2({ - uri, - enabled: isEnabled, - params, - state, -}: GetPostThreadV2QueryProps) { - const qc = useQueryClient() - const agent = useAgent() - const {hasSession} = useSession() - const moderationOpts = useModerationOpts() - const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies() - - const enabled = isEnabled !== false && !!uri && !!moderationOpts - - const query = useQuery({ - enabled, - queryKey: createGetPostThreadV2QueryKey({ - uri, - params, - }), - async queryFn() { - const {data} = await agent.app.bsky.unspecced.getPostThreadV2({ - uri: uri!, - branchingFactor: params.view === 'linear' ? 1 : 10, - below: 10, - sorting: mapSortOptionsToSortID(params.sort), - }) - return data - }, - placeholderData() { - if (!uri) return - const placeholder = getSlicePlaceholder(qc, uri) - if (placeholder) { - return {thread: [placeholder]} - } - return - }, - select(data) { - const threadgate = getThreadgate(data.threadgate) - return { - ...data, - threadgate: { - ...data.threadgate, - record: threadgate, - }, - } - }, - }) - - // TODO map over pages, just like feeds - // - // - sort up just-posted - // - sort down hidden - // - sort down muted - // - sort down blurred? - - const {items, insertReplies} = useThread(query.data?.thread || [], { - hasSession, - params, - threadgateHiddenReplies: mergeThreadgateHiddenReplies( - query.data?.threadgate?.record, - ), - moderationOpts: moderationOpts!, - shownHiddenReplyKinds: state.shownHiddenReplyKinds, - }) - - return { - ...query, - data: { - slices: items, - threadgate: query.data?.threadgate, - }, - insertReplies, - } -} - -const views = { - noUnauthenticated({ - item, - }: { - item: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated - }): Extract { - return { - type: 'threadSliceNoUnauthenticated', - key: item.uri, - slice: item, - } - }, - notFound({ - item, - }: { - item: AppBskyUnspeccedDefs.ThreadItemNotFound - }): Extract { - return { - type: 'threadSliceNotFound', - key: item.uri, - slice: item, - } - }, - blocked({ - item, - }: { - item: AppBskyUnspeccedDefs.ThreadItemBlocked - }): Extract { - return { - type: 'threadSliceBlocked', - key: item.uri, - slice: item, - } - }, - post({ - item, - oneUp, - oneDown, - moderationOpts, - }: { - item: AppBskyUnspeccedDefs.ThreadItemPost - oneUp?: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'][number] - oneDown?: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'][number] - moderationOpts: ModerationOpts - }): Extract { - return { - type: 'threadSlice', - key: item.uri, - slice: { - ...item, - post: { - ...item.post, - record: item.post.record as AppBskyFeedPost.Record, - }, - }, - moderation: moderatePost(item.post, moderationOpts), - ui: { - isAnchor: item.depth === 0, - showParentReplyLine: - !!oneUp && 'depth' in oneUp && oneUp.depth < item.depth, - showChildReplyLine: - !!oneDown && 'depth' in oneDown && oneDown.depth > item.depth, - }, - } - }, -} - -/** - * Get the start and end index of a "branch" of the thread. A "branch" is a - * parent and it's children (not siblings). Returned indices are inclusive of - * the parent and its last child. - * - * items[] (index, depth) - * ├── branch ───── (0, 1) - * ├─┬ branch ───── (1, 1) (start) - * │ ├──┬ leaf ──── (2, 2) - * │ │ └── leaf ── (3, 3) - * │ └── leaf ───── (4, 2) (end) - * ├── branch ───── (5, 1) - * ├── branch ───── (6, 1) - * - * const { start: 1, end: 3 } = getBranch(items, 1, 1) - */ -function getBranch( - thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'], - branchStartIndex: number, - branchStartDepth: number, -) { - let end = branchStartIndex - - for (let ci = branchStartIndex + 1; ci < thread.length; ci++) { - const next = thread[ci] - // ignore unknowns - if (!('depth' in next)) continue - if (next.depth > branchStartDepth) { - end = ci - } else { - end = ci - 1 - break - } - } - - return { - start: branchStartIndex, - end, - } -} - -export function useThread( - thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'], - { - hasSession, - params, - threadgateHiddenReplies, - moderationOpts, - shownHiddenReplyKinds, - }: { - hasSession: boolean - params: PostThreadV2Params - threadgateHiddenReplies: Set - moderationOpts: ModerationOpts - shownHiddenReplyKinds: Set - }, -) { - const isTreeView = params.view === 'tree' - const [shadowSlices, setShadowSlices] = useState< - Record - >({}) - const insertReplies = useCallback((belowUri: string, posts: AppBskyUnspeccedDefs.ThreadItemPost[]) => { - setShadowSlices(p => { - const prev = {...p} - if (belowUri in prev) { - prev[belowUri] = [posts, ...(prev[belowUri] || {})] - } else { - prev[belowUri] = [posts] - } - return prev - }) - }, [setShadowSlices]) - - const sorted = useMemo(() => { - return sortThread(thread, { - threadgateHiddenReplies, - moderationOpts, - }) - }, [thread, threadgateHiddenReplies, moderationOpts]) - - const flattened = useMemo(() => { - const showMuted = shownHiddenReplyKinds.has(HiddenReplyKind.Muted) - const showHidden = shownHiddenReplyKinds.has(HiddenReplyKind.Hidden) - - if (sorted.hidden.length) { - if (showHidden) { - sorted.slices.push(...sorted.hidden) - - if (sorted.muted.length) { - if (showMuted) { - sorted.slices.push(...sorted.muted) - } else { - sorted.slices.push({ - type: 'showHiddenReplies', - key: 'showMutedReplies', - kind: HiddenReplyKind.Muted, - }) - } - } - } else { - sorted.slices.push({ - type: 'showHiddenReplies', - key: 'showHiddenReplies', - kind: HiddenReplyKind.Hidden, - }) - } - } else if (sorted.muted.length) { - if (showMuted) { - sorted.slices.push(...sorted.muted) - } else { - sorted.slices.push({ - type: 'showHiddenReplies', - key: 'showMutedReplies', - kind: HiddenReplyKind.Muted, - }) - } - } - return sorted.slices - }, [sorted, shownHiddenReplyKinds]) - - // TODO should probably done in query cache so deletes flow through the same way - const optimistic = useMemo(() => { - for (let i = 0; i < flattened.length; i++) { - const item = flattened[i] - if (item.type !== 'threadSlice') continue - if (!(item.slice.uri in shadowSlices)) continue - - const replyThreads = shadowSlices[item.slice.uri] - console.log(replyThreads) - - // TODO linear view will work - if (!isTreeView) continue - - for (const thread of replyThreads!) { - for (let ri = 0; ri < thread.length; ri++) { - const post = thread[ri] - post.depth = item.slice.depth + 1 + ri - const view = views.post({ - item: post, - oneUp: undefined, - oneDown: undefined, - moderationOpts, - }) - const insertIndex = i + 1 + ri - flattened.splice( - insertIndex, - // if same post, replace in situ - flattened[insertIndex]?.key === view.key ? 1 : 0, - view - ) - console.log('insert', { - post: post.post.record?.text, - depth: item.slice.depth + 1 + ri, - ri, - spliceIndex: i + 1 + ri, - tree: flattened.map(f => f.slice?.post?.record?.text), - }) - } - - // TODO may not need since inserts will be looped and ignored - // console.log('prev', i) - // i = i - thread.length - // console.log('next', i) - } - } - - return [...flattened] - }, [flattened, shadowSlices, isTreeView]) - - const items = useMemo(() => { - if (hasSession) { - for (let i = 0; i < optimistic.length; i++) { - const slice = optimistic[i] - if ('slice' in slice && slice.slice.depth === 0) { - optimistic.splice(i + 1, 0, { - type: 'replyComposer', - key: 'replyComposer', - }) - break - } - } - } - - return optimistic - }, [optimistic, hasSession]) - - return useMemo( - () => ({ - items, - insertReplies, - }), - [items, insertReplies], - ) -} - -export function sortThread( - thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'], - { - threadgateHiddenReplies, - moderationOpts, - }: { - threadgateHiddenReplies: Set - moderationOpts: ModerationOpts - }, -) { - const slices: Slice[] = [] - const hidden: Slice[] = [] - const muted: Slice[] = [] - - traversal: for (let i = 0; i < thread.length; i++) { - const item = thread[i] - - // ignore unknowns - if (!('depth' in item)) continue - - if (item.depth < 0) { - /* - * Parents are ignored until we find the highlighted post, then we walk - * _up_ from there. - */ - } else if (item.depth === 0) { - if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item)) { - slices.push(views.noUnauthenticated({item})) - } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item)) { - slices.push(views.notFound({item})) - } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item)) { - slices.push(views.blocked({item})) - } else if (AppBskyUnspeccedDefs.isThreadItemPost(item)) { - slices.push( - views.post({ - item, - oneUp: thread[i - 1], - oneDown: thread[i + 1], - moderationOpts, - }), - ) - - parentTraversal: for (let pi = i - 1; pi >= 0; pi--) { - const parentOneDown = thread[pi + 1] - const parent = thread[pi] - const parentOneUp = thread[pi - 1] - - if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent)) { - slices.unshift(views.noUnauthenticated({item: parent})) - break parentTraversal - } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent)) { - slices.unshift(views.notFound({item: parent})) - break parentTraversal - } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent)) { - slices.unshift(views.blocked({item: parent})) - break parentTraversal - } else if (AppBskyUnspeccedDefs.isThreadItemPost(parent)) { - slices.unshift( - views.post({ - item: parent, - oneUp: parentOneUp, - oneDown: parentOneDown, - moderationOpts, - }), - ) - } - } - } - } else if (item.depth > 0) { - /* - * The API does not send down any unavailable replies, so this will - * always be false (for now). If we ever wanted to tombstone them here, - * we could. - */ - const shouldBreak = - AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item) || - AppBskyUnspeccedDefs.isThreadItemNotFound(item) || - AppBskyUnspeccedDefs.isThreadItemBlocked(item) - - if (shouldBreak) { - const branch = getBranch(thread, i, item.depth) - // could insert tombstone - i = branch.end - continue traversal - } else if (AppBskyUnspeccedDefs.isThreadItemPost(item)) { - const lastSlice = slices[slices.length - 1] - const isFirstReply = - lastSlice.type === 'replyComposer' || - (lastSlice.type === 'threadSlice' && lastSlice.slice.depth === 0) - const parent = views.post({ - item, - oneUp: isFirstReply ? undefined : thread[i - 1], - oneDown: thread[i + 1], - moderationOpts, - }) - const parentMod = getModerationState(parent.moderation) - const parentIsHidden = threadgateHiddenReplies.has(item.uri) - const parentIsTopLevelReply = item.depth === 1 - const parentIsModerated = - parentIsHidden || parentMod.blurred || parentMod.muted - - if (!parentIsModerated) { - /* - * Not hidden, so show it - */ - slices.push(parent) - } else { - const branch = getBranch(thread, i, item.depth) - const sortArray = parentMod.muted ? muted : hidden - - if (parentIsTopLevelReply) { - // push branch anchor into sorted array - sortArray.push(parent) - // skip branch anchor in branch traversal - const startIndex = branch.start + 1 - - for (let ci = startIndex; ci <= branch.end; ci++) { - const child = thread[ci] - - if (AppBskyUnspeccedDefs.isThreadItemPost(child)) { - const childPost = views.post({ - item: child, - oneUp: thread[ci - 1], - oneDown: thread[ci + 1], - moderationOpts, - }) - const childMod = getModerationState(childPost.moderation) - const childIsHidden = threadgateHiddenReplies.has(child.uri) - - /* - * If a child is hidden in any way, drop it an its sub-branch - * entirely. To reveal these, the user must navigate to the - * parent post directly. - */ - if (childMod.blurred || childMod.muted || childIsHidden) { - ci = getBranch(thread, ci, child.depth).end - } else { - sortArray.push(childPost) - } - } else { - /* - * Drop the rest of the branch if we hit anything unexpected - */ - break - } - } - } - - /* - * Skip to next branch - */ - i = branch.end - continue traversal - } - } - } - } - - return { - slices, - hidden, - muted, - } -} - -export function getModerationState(moderation: ModerationDecision) { - const modui = moderation.ui('contentList') - const blurred = modui.blur || modui.filter - const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted' - return { - blurred, - muted, - } -} - -export enum HiddenReplyKind { - Hidden = 'hidden', - Muted = 'muted', -} - -export type Slice = - | { - type: 'threadSlice' - key: string - slice: Omit & { - post: Omit & { - record: AppBskyFeedPost.Record - } - } - moderation: ModerationDecision - ui: { - isAnchor: boolean - showParentReplyLine: boolean - showChildReplyLine: boolean - } - } - | { - type: 'threadSliceNoUnauthenticated' - key: string - slice: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated - } - | { - type: 'threadSliceNotFound' - key: string - slice: AppBskyUnspeccedDefs.ThreadItemNotFound - } - | { - type: 'threadSliceBlocked' - key: string - slice: AppBskyUnspeccedDefs.ThreadItemBlocked - } - | { - type: 'replyComposer' - key: string - } - | { - type: 'showHiddenReplies' - key: string - kind: HiddenReplyKind - } - -function getThreadgate( - view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'], -) { - return bsky.dangerousIsType( - view?.record, - AppBskyFeedThreadgate.isRecord, - ) - ? view?.record - : undefined -} - -function getSlicePlaceholder( - queryClient: QueryClient, - uri: string, -): $Typed | void { - let partial - for (let item of yieldPlaceholdersFromQueryCache(queryClient, uri)) { - /* - * Currently, the backend doesn't send full post info in some cases (for - * example, for quoted posts). We use missing `likeCount` as a way to - * detect that. In the future, we should fix this on the backend, which - * will let us always stop on the first result. - * - * TODO can we send in feeds and quotes? - */ - const hasAllInfo = item.post.likeCount != null - if (hasAllInfo) { - return item - } else { - // Keep searching, we might still find a full post in the cache. - partial = item - } - } - return partial -} - -export function* yieldPlaceholdersFromQueryCache( - queryClient: QueryClient, - uri: string, -): Generator<$Typed, void> { - const atUri = new AtUri(uri) - - /* - * Check this thread in the cache first. - * TODO extract just this for shadowing - */ - const queryDatas = - queryClient.getQueriesData({ - queryKey: [getPostThreadV2QueryKeyRoot], - }) - for (const [_queryKey, queryData] of queryDatas) { - if (!queryData) continue - - const {thread} = queryData - - for (const item of thread) { - if (AppBskyUnspeccedDefs.isThreadItemPost(item)) { - if (didOrHandleUriMatches(atUri, item.post)) { - yield { - ...item, - depth: 0, - } - } - - const qp = getEmbeddedPost(item.post.embed) - if (qp && didOrHandleUriMatches(atUri, qp)) { - yield embedViewToSlicePlaceholder(qp) - } - } - } - } - - /* - * Check notifications first. If you have a post in notifications, it's - * often due to a like or a repost, and we want to prioritize a post object - * with >0 likes/reposts over a stale version with no metrics in order to - * avoid a notification->post scroll jump. - */ - for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { - yield postViewToSlicePlaceholder(post) - } - for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { - yield postViewToSlicePlaceholder(post) - } - for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { - yield postViewToSlicePlaceholder(post) - } - for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { - yield postViewToSlicePlaceholder(post) - } - for (let post of findAllPostsInExploreFeedPreviewsQueryData( - queryClient, - uri, - )) { - yield postViewToSlicePlaceholder(post) - } -} - -function postViewToSlicePlaceholder( - post: AppBskyFeedDefs.PostView, -): $Typed { - return { - $type: 'app.bsky.unspecced.defs#threadItemPost', - uri: post.uri, - post, - depth: 0, // reset to 0 for highlighted post - isOPThread: false, // unknown - hasOPLike: false, // unknown - hasUnhydratedReplies: false, // unknown - // TODO test - hasUnhydratedParents: !!(post.record as AppBskyFeedPost.Record).reply, // unknown - } -} - -function embedViewToSlicePlaceholder( - record: AppBskyEmbedRecord.ViewRecord, -): $Typed { - return { - $type: 'app.bsky.unspecced.defs#threadItemPost', - uri: record.uri, - post: embedViewRecordToPostView(record), - depth: 0, // reset to 0 for highlighted post - isOPThread: false, // unknown - hasOPLike: false, // unknown - hasUnhydratedReplies: false, // unknown - // TODO test - hasUnhydratedParents: !!(record.value as AppBskyFeedPost.Record).reply, // unknown - } -} diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts new file mode 100644 index 0000000000..88cd2ccb60 --- /dev/null +++ b/src/state/queries/usePostThread/index.ts @@ -0,0 +1,93 @@ +import {useQuery, useQueryClient} from '@tanstack/react-query' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {getThreadPlaceholder} from '#/state/queries/usePostThread/queryCache' +import {flatten,sort} from '#/state/queries/usePostThread/traversal' +import { + createPostThreadQueryKey, + HiddenReplyKind, + type UsePostThreadProps, +} from '#/state/queries/usePostThread/types' +import { + getThreadgateRecord, + mapSortOptionsToSortID, +} from '#/state/queries/usePostThread/utils' +import {useAgent, useSession} from '#/state/session' +import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' + +export * from '#/state/queries/usePostThread/types' + +export function usePostThread({ + uri, + enabled: isEnabled, + params, + state, +}: UsePostThreadProps) { + const qc = useQueryClient() + const agent = useAgent() + const {hasSession} = useSession() + const moderationOpts = useModerationOpts() + const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies() + + const enabled = isEnabled !== false && !!uri && !!moderationOpts + + const query = useQuery({ + enabled, + queryKey: createPostThreadQueryKey({ + uri, + params, + }), + async queryFn() { + const {data} = await agent.app.bsky.unspecced.getPostThreadV2({ + uri: uri!, + branchingFactor: params.view === 'linear' ? 1 : 10, + below: 10, + sorting: mapSortOptionsToSortID(params.sort), + }) + return data + }, + placeholderData() { + if (!uri) return + const placeholder = getThreadPlaceholder(qc, uri) + if (placeholder) { + return {thread: [placeholder]} + } + return + }, + select(data) { + const threadgate = getThreadgateRecord(data.threadgate) + return { + ...data, + threadgate: { + ...data.threadgate, + record: threadgate, + }, + } + }, + }) + + // TODO map over pages, just like feeds + + const items = flatten( + sort(query.data?.thread || [], { + threadgateHiddenReplies: mergeThreadgateHiddenReplies( + query.data?.threadgate?.record, + ), + moderationOpts: moderationOpts!, + }), + { + hasSession, + showMuted: state.shownHiddenReplyKinds.has(HiddenReplyKind.Muted), + showHidden: state.shownHiddenReplyKinds.has(HiddenReplyKind.Hidden), + }, + ) + + return { + ...query, + data: { + slices: items, + threadgate: query.data?.threadgate, + }, + insertReplies: () => {}, + } +} diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts new file mode 100644 index 0000000000..94bf838df7 --- /dev/null +++ b/src/state/queries/usePostThread/queryCache.ts @@ -0,0 +1,106 @@ +import { + type $Typed, + AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadV2, + AtUri, +} from '@atproto/api' +import {type QueryClient} from '@tanstack/react-query' + +import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' +import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' +import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' +import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' +import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' +import {postThreadQueryKeyRoot} from '#/state/queries/usePostThread/types' +import { + embedViewToThreadPlaceholder, + postViewToThreadPlaceholder, +} from '#/state/queries/usePostThread/views' +import {didOrHandleUriMatches, getEmbeddedPost} from '#/state/queries/util' + +export function getThreadPlaceholder( + queryClient: QueryClient, + uri: string, +): $Typed | void { + let partial + for (let item of getThreadPlaceholderCandidates(queryClient, uri)) { + /* + * Currently, the backend doesn't send full post info in some cases (for + * example, for quoted posts). We use missing `likeCount` as a way to + * detect that. In the future, we should fix this on the backend, which + * will let us always stop on the first result. + * + * TODO can we send in feeds and quotes? + */ + const hasAllInfo = item.post.likeCount != null + if (hasAllInfo) { + return item + } else { + // Keep searching, we might still find a full post in the cache. + partial = item + } + } + return partial +} + +export function* getThreadPlaceholderCandidates( + queryClient: QueryClient, + uri: string, +): Generator<$Typed, void> { + const atUri = new AtUri(uri) + + /* + * Check this thread in the cache first. + * TODO extract just this for shadowing + */ + const queryDatas = + queryClient.getQueriesData({ + queryKey: [postThreadQueryKeyRoot], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData) continue + + const {thread} = queryData + + for (const item of thread) { + if (AppBskyUnspeccedDefs.isThreadItemPost(item)) { + if (didOrHandleUriMatches(atUri, item.post)) { + yield { + ...item, + depth: 0, + } + } + + const qp = getEmbeddedPost(item.post.embed) + if (qp && didOrHandleUriMatches(atUri, qp)) { + yield embedViewToThreadPlaceholder(qp) + } + } + } + } + + /* + * Check notifications first. If you have a post in notifications, it's + * often due to a like or a repost, and we want to prioritize a post object + * with >0 likes/reposts over a stale version with no metrics in order to + * avoid a notification->post scroll jump. + */ + for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { + yield postViewToThreadPlaceholder(post) + } + for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { + yield postViewToThreadPlaceholder(post) + } + for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { + yield postViewToThreadPlaceholder(post) + } + for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { + yield postViewToThreadPlaceholder(post) + } + for (let post of findAllPostsInExploreFeedPreviewsQueryData( + queryClient, + uri, + )) { + yield postViewToThreadPlaceholder(post) + } +} diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts new file mode 100644 index 0000000000..e57e0ddb39 --- /dev/null +++ b/src/state/queries/usePostThread/traversal.ts @@ -0,0 +1,289 @@ +import { + AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadV2, + type ModerationDecision, + type ModerationOpts, +} from '@atproto/api' + +import {HiddenReplyKind,type Slice} from '#/state/queries/usePostThread/types' +import * as views from '#/state/queries/usePostThread/views' + +export function flatten( + sorted: ReturnType, + { + hasSession, + showMuted, + showHidden, + }: { + hasSession: boolean + showMuted: boolean + showHidden: boolean + }, +) { + const flattened: Slice[] = sorted.slices + + if (sorted.hidden.length) { + if (showHidden) { + flattened.push(...sorted.hidden) + + if (sorted.muted.length) { + if (showMuted) { + flattened.push(...sorted.muted) + } else { + flattened.push({ + type: 'showHiddenReplies', + key: 'showMutedReplies', + kind: HiddenReplyKind.Muted, + }) + } + } + } else { + flattened.push({ + type: 'showHiddenReplies', + key: 'showHiddenReplies', + kind: HiddenReplyKind.Hidden, + }) + } + } else if (sorted.muted.length) { + if (showMuted) { + flattened.push(...sorted.muted) + } else { + flattened.push({ + type: 'showHiddenReplies', + key: 'showMutedReplies', + kind: HiddenReplyKind.Muted, + }) + } + } + + if (hasSession) { + for (let i = 0; i < flattened.length; i++) { + const slice = flattened[i] + if ('slice' in slice && slice.slice.depth === 0) { + flattened.splice(i + 1, 0, { + type: 'replyComposer', + key: 'replyComposer', + }) + break + } + } + } + + return flattened +} + +export function sort( + thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'], + { + threadgateHiddenReplies, + moderationOpts, + }: { + threadgateHiddenReplies: Set + moderationOpts: ModerationOpts + }, +) { + const slices: Slice[] = [] + const hidden: Slice[] = [] + const muted: Slice[] = [] + + traversal: for (let i = 0; i < thread.length; i++) { + const item = thread[i] + + // ignore unknowns + if (!('depth' in item)) continue + + if (item.depth < 0) { + /* + * Parents are ignored until we find the highlighted post, then we walk + * _up_ from there. + */ + } else if (item.depth === 0) { + if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item)) { + slices.push(views.noUnauthenticated({item})) + } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item)) { + slices.push(views.notFound({item})) + } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item)) { + slices.push(views.blocked({item})) + } else if (AppBskyUnspeccedDefs.isThreadItemPost(item)) { + slices.push( + views.post({ + item, + oneUp: thread[i - 1], + oneDown: thread[i + 1], + moderationOpts, + }), + ) + + parentTraversal: for (let pi = i - 1; pi >= 0; pi--) { + const parentOneDown = thread[pi + 1] + const parent = thread[pi] + const parentOneUp = thread[pi - 1] + + if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent)) { + slices.unshift(views.noUnauthenticated({item: parent})) + break parentTraversal + } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent)) { + slices.unshift(views.notFound({item: parent})) + break parentTraversal + } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent)) { + slices.unshift(views.blocked({item: parent})) + break parentTraversal + } else if (AppBskyUnspeccedDefs.isThreadItemPost(parent)) { + slices.unshift( + views.post({ + item: parent, + oneUp: parentOneUp, + oneDown: parentOneDown, + moderationOpts, + }), + ) + } + } + } + } else if (item.depth > 0) { + /* + * The API does not send down any unavailable replies, so this will + * always be false (for now). If we ever wanted to tombstone them here, + * we could. + */ + const shouldBreak = + AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item) || + AppBskyUnspeccedDefs.isThreadItemNotFound(item) || + AppBskyUnspeccedDefs.isThreadItemBlocked(item) + + if (shouldBreak) { + const branch = getBranch(thread, i, item.depth) + // could insert tombstone + i = branch.end + continue traversal + } else if (AppBskyUnspeccedDefs.isThreadItemPost(item)) { + const lastSlice = slices[slices.length - 1] + const isFirstReply = + lastSlice.type === 'replyComposer' || + (lastSlice.type === 'threadSlice' && lastSlice.slice.depth === 0) + const parent = views.post({ + item, + oneUp: isFirstReply ? undefined : thread[i - 1], + oneDown: thread[i + 1], + moderationOpts, + }) + const parentMod = getModerationState(parent.moderation) + const parentIsHidden = threadgateHiddenReplies.has(item.uri) + const parentIsTopLevelReply = item.depth === 1 + const parentIsModerated = + parentIsHidden || parentMod.blurred || parentMod.muted + + if (!parentIsModerated) { + /* + * Not hidden, so show it + */ + slices.push(parent) + } else { + const branch = getBranch(thread, i, item.depth) + const sortArray = parentMod.muted ? muted : hidden + + if (parentIsTopLevelReply) { + // push branch anchor into sorted array + sortArray.push(parent) + // skip branch anchor in branch traversal + const startIndex = branch.start + 1 + + for (let ci = startIndex; ci <= branch.end; ci++) { + const child = thread[ci] + + if (AppBskyUnspeccedDefs.isThreadItemPost(child)) { + const childPost = views.post({ + item: child, + oneUp: thread[ci - 1], + oneDown: thread[ci + 1], + moderationOpts, + }) + const childMod = getModerationState(childPost.moderation) + const childIsHidden = threadgateHiddenReplies.has(child.uri) + + /* + * If a child is hidden in any way, drop it an its sub-branch + * entirely. To reveal these, the user must navigate to the + * parent post directly. + */ + if (childMod.blurred || childMod.muted || childIsHidden) { + ci = getBranch(thread, ci, child.depth).end + } else { + sortArray.push(childPost) + } + } else { + /* + * Drop the rest of the branch if we hit anything unexpected + */ + break + } + } + } + + /* + * Skip to next branch + */ + i = branch.end + continue traversal + } + } + } + } + + return { + slices, + hidden, + muted, + } +} + +/** + * Get the start and end index of a "branch" of the thread. A "branch" is a + * parent and it's children (not siblings). Returned indices are inclusive of + * the parent and its last child. + * + * items[] (index, depth) + * ├── branch ───── (0, 1) + * ├─┬ branch ───── (1, 1) (start) + * │ ├──┬ leaf ──── (2, 2) + * │ │ └── leaf ── (3, 3) + * │ └── leaf ───── (4, 2) (end) + * ├── branch ───── (5, 1) + * ├── branch ───── (6, 1) + * + * const { start: 1, end: 3 } = getBranch(items, 1, 1) + */ +function getBranch( + thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'], + branchStartIndex: number, + branchStartDepth: number, +) { + let end = branchStartIndex + + for (let ci = branchStartIndex + 1; ci < thread.length; ci++) { + const next = thread[ci] + // ignore unknowns + if (!('depth' in next)) continue + if (next.depth > branchStartDepth) { + end = ci + } else { + end = ci - 1 + break + } + } + + return { + start: branchStartIndex, + end, + } +} + +export function getModerationState(moderation: ModerationDecision) { + const modui = moderation.ui('contentList') + const blurred = modui.blur || modui.filter + const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted' + return { + blurred, + muted, + } +} diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts new file mode 100644 index 0000000000..4b4bae3ae8 --- /dev/null +++ b/src/state/queries/usePostThread/types.ts @@ -0,0 +1,74 @@ +import { + type AppBskyFeedDefs, + type AppBskyFeedPost, + type AppBskyUnspeccedDefs, + type BskyThreadViewPreference, + type ModerationDecision, +} from '@atproto/api' + +export const postThreadQueryKeyRoot = 'getPostThreadV2' as const + +export const createPostThreadQueryKey = ( + props: Pick, +) => [postThreadQueryKeyRoot, props] as const + +export type PostThreadParams = { + view: 'tree' | 'linear' + sort: 'hotness' | 'oldest' | 'newest' | 'most-likes' | 'random' | string + prioritizeFollows: BskyThreadViewPreference['prioritizeFollowedUsers'] +} + +export type UsePostThreadProps = { + uri?: string + enabled?: boolean + params: PostThreadParams + state: { + shownHiddenReplyKinds: Set + } +} + +export enum HiddenReplyKind { + Hidden = 'hidden', + Muted = 'muted', +} + +export type Slice = + | { + type: 'threadSlice' + key: string + slice: Omit & { + post: Omit & { + record: AppBskyFeedPost.Record + } + } + moderation: ModerationDecision + ui: { + isAnchor: boolean + showParentReplyLine: boolean + showChildReplyLine: boolean + } + } + | { + type: 'threadSliceNoUnauthenticated' + key: string + slice: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated + } + | { + type: 'threadSliceNotFound' + key: string + slice: AppBskyUnspeccedDefs.ThreadItemNotFound + } + | { + type: 'threadSliceBlocked' + key: string + slice: AppBskyUnspeccedDefs.ThreadItemBlocked + } + | { + type: 'replyComposer' + key: string + } + | { + type: 'showHiddenReplies' + key: string + kind: HiddenReplyKind + } diff --git a/src/state/queries/usePostThread/utils.ts b/src/state/queries/usePostThread/utils.ts new file mode 100644 index 0000000000..2436cecba0 --- /dev/null +++ b/src/state/queries/usePostThread/utils.ts @@ -0,0 +1,34 @@ +import { + APP_BSKY_UNSPECCED, + AppBskyFeedThreadgate, + type AppBskyUnspeccedGetPostThreadV2, +} from '@atproto/api' + +import {type PostThreadParams} from '#/state/queries/usePostThread/types' +import * as bsky from '#/types/bsky' + +export function mapSortOptionsToSortID(sort: PostThreadParams['sort']) { + switch (sort) { + case 'hotness': + return APP_BSKY_UNSPECCED.GetPostThreadV2Hotness + case 'oldest': + return APP_BSKY_UNSPECCED.GetPostThreadV2Oldest + case 'newest': + return APP_BSKY_UNSPECCED.GetPostThreadV2Newest + case 'most-likes': + return APP_BSKY_UNSPECCED.GetPostThreadV2MostLikes + default: + return APP_BSKY_UNSPECCED.GetPostThreadV2Hotness + } +} + +export function getThreadgateRecord( + view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'], +) { + return bsky.dangerousIsType( + view?.record, + AppBskyFeedThreadgate.isRecord, + ) + ? view?.record + : undefined +} diff --git a/src/state/queries/usePostThread/views.ts b/src/state/queries/usePostThread/views.ts new file mode 100644 index 0000000000..b2f4563374 --- /dev/null +++ b/src/state/queries/usePostThread/views.ts @@ -0,0 +1,113 @@ +import { + type $Typed, + type AppBskyEmbedRecord, + type AppBskyFeedDefs, + type AppBskyFeedPost, + type AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadV2, + moderatePost, + type ModerationOpts, +} from '@atproto/api' + +import {type Slice} from '#/state/queries/usePostThread/types' +import {embedViewRecordToPostView} from '#/state/queries/util' + +export function noUnauthenticated({ + item, +}: { + item: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated +}): Extract { + return { + type: 'threadSliceNoUnauthenticated', + key: item.uri, + slice: item, + } +} + +export function notFound({ + item, +}: { + item: AppBskyUnspeccedDefs.ThreadItemNotFound +}): Extract { + return { + type: 'threadSliceNotFound', + key: item.uri, + slice: item, + } +} + +export function blocked({ + item, +}: { + item: AppBskyUnspeccedDefs.ThreadItemBlocked +}): Extract { + return { + type: 'threadSliceBlocked', + key: item.uri, + slice: item, + } +} + +export function post({ + item, + oneUp, + oneDown, + moderationOpts, +}: { + item: AppBskyUnspeccedDefs.ThreadItemPost + oneUp?: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'][number] + oneDown?: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'][number] + moderationOpts: ModerationOpts +}): Extract { + return { + type: 'threadSlice', + key: item.uri, + slice: { + ...item, + post: { + ...item.post, + record: item.post.record as AppBskyFeedPost.Record, + }, + }, + moderation: moderatePost(item.post, moderationOpts), + ui: { + isAnchor: item.depth === 0, + showParentReplyLine: + !!oneUp && 'depth' in oneUp && oneUp.depth < item.depth, + showChildReplyLine: + !!oneDown && 'depth' in oneDown && oneDown.depth > item.depth, + }, + } +} + +export function postViewToThreadPlaceholder( + post: AppBskyFeedDefs.PostView, +): $Typed { + return { + $type: 'app.bsky.unspecced.defs#threadItemPost', + uri: post.uri, + post, + depth: 0, // reset to 0 for highlighted post + isOPThread: false, // unknown + hasOPLike: false, // unknown + hasUnhydratedReplies: false, // unknown + // TODO test + hasUnhydratedParents: !!(post.record as AppBskyFeedPost.Record).reply, // unknown + } +} + +export function embedViewToThreadPlaceholder( + record: AppBskyEmbedRecord.ViewRecord, +): $Typed { + return { + $type: 'app.bsky.unspecced.defs#threadItemPost', + uri: record.uri, + post: embedViewRecordToPostView(record), + depth: 0, // reset to 0 for highlighted post + isOPThread: false, // unknown + hasOPLike: false, // unknown + hasUnhydratedReplies: false, // unknown + // TODO test + hasUnhydratedParents: !!(record.value as AppBskyFeedPost.Record).reply, // unknown + } +} diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index a4f3106300..a26512dd7d 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,34 +1,39 @@ import {useCallback, useMemo, useRef, useState} from 'react' -import {StyleSheet, useWindowDimensions, View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' +import {useWindowDimensions, View} from 'react-native' +import {type AppBskyUnspeccedDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {AppBskyUnspeccedDefs} from '@atproto/api' +import {useFocusEffect} from '@react-navigation/native' -import {isNative} from '#/platform/detection' -import {cleanError} from '#/lib/strings/errors' import {HITSLOP_10} from '#/lib/constants' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {type CommonNavigatorParams, type NativeStackScreenProps} from '#/lib/routes/types' +import {cleanError} from '#/lib/strings/errors' import {makeRecordUri} from '#/lib/strings/url-helpers' +import {isNative} from '#/platform/detection' import {useSetMinimalShellMode} from '#/state/shell' -import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread' -import * as Layout from '#/components/Layout' -import {useSession} from '#/state/session' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +{ + /* import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread' */ +} import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' -import {useGetPostThreadV2, Slice, HiddenReplyKind} from '#/state/queries/useGetPostThreadV2' -import * as Menu from '#/components/Menu' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {ScrollProvider} from '#/lib/ScrollContext' +import {usePreferencesQuery} from '#/state/queries/preferences' +import { + HiddenReplyKind, + type Slice, + usePostThread, +} from '#/state/queries/usePostThread' +import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' +import {PostThreadItem} from '#/view/com/post-thread/PostThreadItem' +import {PostThreadShowHiddenReplies} from '#/view/com/post-thread/PostThreadShowHiddenReplies' +import {List, type ListMethods} from '#/view/com/util/List' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' +import * as Layout from '#/components/Layout' +import {ListFooter} from '#/components/Lists' +import * as Menu from '#/components/Menu' import {Text} from '#/components/Typography' -import {ScrollProvider} from '#/lib/ScrollContext' -import {List, ListMethods} from '#/view/com/util/List' -import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' -import {PostThreadItem} from '#/view/com/post-thread/PostThreadItem' -import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' -import {usePreferencesQuery} from '#/state/queries/preferences' -import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {PostThreadShowHiddenReplies} from '#/view/com/post-thread/PostThreadShowHiddenReplies' const MAINTAIN_VISIBLE_CONTENT_POSITION = { // We don't insert any elements before the root row while loading. @@ -91,23 +96,26 @@ function useThreadPreferences() { const isLoaded = !!prevServerPrefs - return useMemo(() => ({ - isLoaded, - sortReplies, - setSortReplies, - prioritizeFollowedUsers, - setPrioritizeFollowedUsers, - treeViewEnabled, - setTreeViewEnabled, - }), [ - isLoaded, - sortReplies, - setSortReplies, - prioritizeFollowedUsers, - setPrioritizeFollowedUsers, - treeViewEnabled, - setTreeViewEnabled, - ]) + return useMemo( + () => ({ + isLoaded, + sortReplies, + setSortReplies, + prioritizeFollowedUsers, + setPrioritizeFollowedUsers, + treeViewEnabled, + setTreeViewEnabled, + }), + [ + isLoaded, + sortReplies, + setSortReplies, + prioritizeFollowedUsers, + setPrioritizeFollowedUsers, + treeViewEnabled, + setTreeViewEnabled, + ], + ) } export function Inner({uri}: {uri: string | undefined}) { @@ -127,39 +135,42 @@ export function Inner({uri}: {uri: string | undefined}) { setTreeViewEnabled, } = useThreadPreferences() - const [shownHiddenReplyKinds, setShownHiddenReplyKinds] = useState>(new Set()) + const [shownHiddenReplyKinds, setShownHiddenReplyKinds] = useState< + Set + >(new Set()) - const {isFetching, isPlaceholderData, error, data, refetch, insertReplies} = useGetPostThreadV2({ - uri, - enabled: isThreadPreferencesLoaded, - params: { - sort: sortReplies, - view: treeViewEnabled ? 'tree' : 'linear', - prioritizeFollows: prioritizeFollowedUsers, - }, - state: { - shownHiddenReplyKinds, - } - }) + const {isFetching, isPlaceholderData, error, data, refetch, insertReplies} = + usePostThread({ + uri, + enabled: isThreadPreferencesLoaded, + params: { + sort: sortReplies, + view: treeViewEnabled ? 'tree' : 'linear', + prioritizeFollows: prioritizeFollowedUsers, + }, + state: { + shownHiddenReplyKinds, + }, + }) const ref = useRef(null) const layoutHeaderRef = useRef(null) const anchorPostRef = useRef(null) - const optimisticOnPostReply = ({ - post, - }: { - post: AppBskyUnspeccedDefs.ThreadItemPost, - }) => (_: any, posts: AppBskyUnspeccedDefs.ThreadItemPost[]) => { - if (posts.length) { - // TODO get parent and update reply count? - insertReplies(post.uri, posts) + const optimisticOnPostReply = + ({post}: {post: AppBskyUnspeccedDefs.ThreadItemPost}) => + (_: any, posts: AppBskyUnspeccedDefs.ThreadItemPost[]) => { + if (posts.length) { + // TODO get parent and update reply count? + insertReplies(post.uri, posts) + } } - } const {openComposer} = useOpenComposer() const onReplyToAnchor = () => { - const anchorPost = data?.slices.find(slice => slice.type === 'threadSlice' && slice.ui.isAnchor) + const anchorPost = data?.slices.find( + slice => slice.type === 'threadSlice' && slice.ui.isAnchor, + ) if (anchorPost?.type !== 'threadSlice') { return } @@ -174,7 +185,7 @@ export function Inner({uri}: {uri: string | undefined}) { moderation: anchorPost.moderation, }, // @ts-expect-error TODO - onPost: optimisticOnPostReply({post:anchorPost.slice}), + onPost: optimisticOnPostReply({post: anchorPost.slice}), }) } @@ -200,10 +211,11 @@ export function Inner({uri}: {uri: string | undefined}) { item.ui.showParentReplyLine || !!item.slice.hasUnhydratedParents } // !!hasUnrevealedParents // TODO overrideBlur={ - shownHiddenReplyKinds.has(HiddenReplyKind.Muted) && item.slice.depth > 0 + shownHiddenReplyKinds.has(HiddenReplyKind.Muted) && + item.slice.depth > 0 } // @ts-expect-error TODO - onPostReply={optimisticOnPostReply({post:item.slice})} + onPostReply={optimisticOnPostReply({post: item.slice})} hideTopBorder={index === 0} // && !item.slice.isParentLoading} // TODO /> @@ -239,14 +251,18 @@ export function Inner({uri}: {uri: string | undefined}) { } else if (item.type === 'replyComposer') { return ( - {gtPhone && } + {gtPhone && ( + + )} ) } else if (item.type === 'showHiddenReplies') { return ( setShownHiddenReplyKinds(kinds => new Set([...kinds, item.kind]))} + onPress={() => + setShownHiddenReplyKinds(kinds => new Set([...kinds, item.kind])) + } /> ) } @@ -324,9 +340,9 @@ export function Inner({uri}: {uri: string | undefined}) { * @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition */ maintainVisibleContentPosition={ - isNative // && hasParents // TODO not sure we need this - ? MAINTAIN_VISIBLE_CONTENT_POSITION - : undefined + isNative // && hasParents // TODO not sure we need this + ? MAINTAIN_VISIBLE_CONTENT_POSITION + : undefined } desktopFixedHeight // removeClippedSubviews={isAndroid ? false : undefined} @@ -361,7 +377,7 @@ function PostThreadError({error}: {error: Error}) { const {_} = useLingui() // TODO use new cleanError hook - const {title, message} = useMemo(() => { + const {title: _title, message: _message} = useMemo(() => { let title = _(msg`An error occurred`) let message = cleanError(error)