diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index 551fedc8b4..4a75f85a79 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -46,7 +46,13 @@ export interface ThreadCtx { depth: number isHighlightedPost?: boolean hasMore?: boolean + /** + * Means the loading state has parents + */ isParentLoading?: boolean + /** + * Means the loading state has replies + */ isChildLoading?: boolean isSelfThread?: boolean hasMoreSelfThread?: boolean @@ -108,6 +114,7 @@ export function usePostThreadQuery(uri: string | undefined) { depth: REPLY_TREE_DEPTH, }) if (res.success) { + await new Promise(y => setTimeout(y, 3e3)) // wait for the next tick const thread = responseToThreadNodes(res.data.thread) annotateSelfThread(thread) return { diff --git a/src/state/queries/useGetPostThreadV2.ts b/src/state/queries/useGetPostThreadV2.ts new file mode 100644 index 0000000000..38204d46cd --- /dev/null +++ b/src/state/queries/useGetPostThreadV2.ts @@ -0,0 +1,373 @@ +import {useCallback} from 'react' +import { + $Typed, + AtUri, + AppBskyFeedDefs, + AppBskyFeedThreadgate, + AppBskyFeedGetPostThreadV2, + ModerationOpts, + BskyThreadViewPreference, + moderatePost, + ModerationDecision, + AppBskyEmbedRecord, + AppBskyFeedPost, +} 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 PostThreadV2Options = { + 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 GetPostThreadV2Params = { + uri?: string + enabled?: boolean + options: PostThreadV2Options +} + +export type GetPostThreadV2QueryData = { + slices: Slice[] + threadgate?: AppBskyFeedDefs.ThreadgateView +} + +export function useGetPostThreadV2({ + uri, + enabled: isEnabled, + options, +}: GetPostThreadV2Params) { + const qc = useQueryClient() + const agent = useAgent() + const {hasSession} = useSession() + const moderationOpts = useModerationOpts() + const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies() + + const enabled = isEnabled !== false && !!uri && !!moderationOpts + + const select = useCallback( + (data: AppBskyFeedGetPostThreadV2.OutputSchema) => { + const threadgate = getThreadgate(data.threadgate) + return { + slices: buildSlices(data.thread, { + hasSession, + options, + threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate), + moderationOpts: moderationOpts!, + }), + threadgate: { + ...data.threadgate, + record: threadgate, + }, + } + }, + [hasSession, options, moderationOpts, mergeThreadgateHiddenReplies], + ) + + return useQuery({ + enabled, + queryKey: createGetPostThreadV2QueryKey({ + uri, + options, + }), + async queryFn() { + const {data} = await agent.app.bsky.feed.getPostThreadV2({ + uri: uri!, + depth: 10, + }) + return data + }, + placeholderData() { + if (!uri) return + const placeholder = getSlicePlaceholder(qc, uri) + if (placeholder) { + return {thread: [placeholder]} + } + return + }, + select, + }) +} + +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: AppBskyFeedDefs.ThreadItemNoUnauthenticated + } + | { + type: 'threadSliceNotFound' + key: string + slice: AppBskyFeedDefs.ThreadItemNotFound + } + | { + type: 'threadSliceBlocked' + key: string + slice: AppBskyFeedDefs.ThreadItemBlocked + } + | { + type: 'replyComposer' + key: string + } + | { + type: 'showHiddenReplies' + key: string + } + | { + // TODO needed? + type: 'showMutedReplies' + key: string + } + +export function buildSlices( + thread: AppBskyFeedGetPostThreadV2.OutputSchema['thread'], + { + hasSession, + options, + threadgateHiddenReplies, + moderationOpts, + }: { + hasSession: boolean + options: PostThreadV2Options + threadgateHiddenReplies: Set + moderationOpts: ModerationOpts + }, +): Slice[] { + const slices: Slice[] = [] + + for (let i = 0; i < thread.length; i++) { + const prev = thread[i - 1] + const slice = thread[i] + const next = thread[i + 1] + + if (AppBskyFeedDefs.isThreadItemNoUnauthenticated(slice)) { + slices.push({ + type: 'threadSliceNoUnauthenticated', + key: slice.uri, + slice, + }) + } else if (AppBskyFeedDefs.isThreadItemNotFound(slice)) { + slices.push({ + type: 'threadSliceNotFound', + key: slice.uri, + slice, + }) + } else if (AppBskyFeedDefs.isThreadItemBlocked(slice)) { + slices.push({ + type: 'threadSliceBlocked', + key: slice.uri, + slice, + }) + } else if (AppBskyFeedDefs.isThreadItemPost(slice)) { + slices.push({ + type: 'threadSlice', + key: slice.uri, + slice: { + ...slice, + post: { + ...slice.post, + record: slice.post.record as AppBskyFeedPost.Record, + } + }, + moderation: moderatePost(slice.post, moderationOpts), + ui: { + isAnchor: slice.depth === 0, + showParentReplyLine: + !!prev && + AppBskyFeedDefs.isThreadItemPost(prev) && + prev.depth < slice.depth, + showChildReplyLine: + !!next && + AppBskyFeedDefs.isThreadItemPost(next) && + next.depth > slice.depth, + }, + }) + + if (slice.depth === 0 && hasSession) { + slices.push({ + type: 'replyComposer', + key: 'replyComposer', + }) + } + } + } + + return slices +} + +function getThreadgate( + view: AppBskyFeedGetPostThreadV2.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 (AppBskyFeedDefs.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.feed.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.feed.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/threadgate-hidden-replies.tsx b/src/state/threadgate-hidden-replies.tsx index 60806f5706..9d116c7f9f 100644 --- a/src/state/threadgate-hidden-replies.tsx +++ b/src/state/threadgate-hidden-replies.tsx @@ -83,3 +83,17 @@ export function useMergedThreadgateHiddenReplies({ return set }, [uris, recentlyUnhiddenUris, threadgateRecord]) } + +export function useMergeThreadgateHiddenReplies() { + const {uris, recentlyUnhiddenUris} = useThreadgateHiddenReplyUris() + return React.useCallback( + (threadgate?: AppBskyFeedThreadgate.Record) => { + const set = new Set([...(threadgate?.hiddenReplies || []), ...uris]) + for (const uri of recentlyUnhiddenUris) { + set.delete(uri) + } + return set + }, + [uris, recentlyUnhiddenUris], + ) +} diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index d974ce6b53..69c72ebb38 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -90,6 +90,7 @@ type ThreadSkeletonParts = { } const keyExtractor = (item: RowItem) => { + console.log(item._reactKey) return item._reactKey } @@ -257,6 +258,7 @@ export function PostThread({uri}: {uri: string | undefined}) { fetchedAt, randomCache, ]) + console.log({thread, skeleton}) const error = React.useMemo(() => { if (AppBskyFeedDefs.isNotFoundPost(thread)) { @@ -338,8 +340,11 @@ export function PostThread({uri}: {uri: string | undefined}) { const headerNode = headerRef.current if (postNode && headerNode) { let pageY = (postNode as any as Element).getBoundingClientRect().top + console.log({pageY}) pageY -= (headerNode as any as Element).getBoundingClientRect().height + console.log({pageY}) pageY = Math.max(0, pageY) + console.log({pageY}) ref.current?.scrollToOffset({ animated: false, offset: pageY, @@ -561,6 +566,9 @@ export function PostThread({uri}: {uri: string | undefined}) { onEndReached={onEndReached} onEndReachedThreshold={2} onScrollToTop={onScrollToTop} + /** + * @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition + */ maintainVisibleContentPosition={ isNative && hasParents ? MAINTAIN_VISIBLE_CONTENT_POSITION diff --git a/src/view/com/post-thread/PostThreadV2.tsx b/src/view/com/post-thread/PostThreadV2.tsx new file mode 100644 index 0000000000..9d9db3290b --- /dev/null +++ b/src/view/com/post-thread/PostThreadV2.tsx @@ -0,0 +1,899 @@ +import React, {memo, useRef, useState} from 'react' +import {StyleSheet, useWindowDimensions, View} from 'react-native' +import {runOnJS} from 'react-native-reanimated' +import Animated from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import { + AppBskyFeedDefs, + AppBskyFeedThreadgate, + moderatePost, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {HITSLOP_10} from '#/lib/constants' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' +import {useSetTitle} from '#/lib/hooks/useSetTitle' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {clamp} from '#/lib/numbers' +import {ScrollProvider} from '#/lib/ScrollContext' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {cleanError} from '#/lib/strings/errors' +import {isAndroid, isNative, isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import { + fillThreadModerationCache, + sortThread, + ThreadBlocked, + ThreadModerationCache, + ThreadNode, + ThreadNotFound, + ThreadPost, + usePostThreadQuery, +} from '#/state/queries/post-thread' +import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {useComposerControls} from '#/state/shell' +import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {List, ListMethods} from '#/view/com/util/List' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' +import {Header} from '#/components/Layout' +import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' +import * as Menu from '#/components/Menu' +import {Text} from '#/components/Typography' +import {PostThreadComposePrompt} from './PostThreadComposePrompt' +import {PostThreadItem} from './PostThreadItem' +import {PostThreadLoadMore} from './PostThreadLoadMore' +import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies' + +// FlatList maintainVisibleContentPosition breaks if too many items +// are prepended. This seems to be an optimal number based on *shrug*. +const PARENTS_CHUNK_SIZE = 15 + +const MAINTAIN_VISIBLE_CONTENT_POSITION = { + // We don't insert any elements before the root row while loading. + // So the row we want to use as the scroll anchor is the first row. + minIndexForVisible: 0, +} + +const REPLY_PROMPT = {_reactKey: '__reply__'} +const LOAD_MORE = {_reactKey: '__load_more__'} +const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'} +const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'} + +enum HiddenRepliesState { + Hide, + Show, + ShowAndOverridePostHider, +} + +type YieldedItem = + | ThreadPost + | ThreadBlocked + | ThreadNotFound + | typeof SHOW_HIDDEN_REPLIES + | typeof SHOW_MUTED_REPLIES +type RowItem = + | YieldedItem + // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape. + | typeof REPLY_PROMPT + | typeof LOAD_MORE + +type ThreadSkeletonParts = { + parents: YieldedItem[] + highlightedPost: ThreadNode + replies: YieldedItem[] +} + +const keyExtractor = (item: RowItem) => { + return item._reactKey +} + +export function PostThread({uri}: {uri: string | undefined}) { + const {hasSession, currentAccount} = useSession() + const {_} = useLingui() + const t = useTheme() + const {isMobile} = useWebMediaQueries() + const initialNumToRender = useInitialNumToRender() + const {height: windowHeight} = useWindowDimensions() + const [hiddenRepliesState, setHiddenRepliesState] = React.useState( + HiddenRepliesState.Hide, + ) + const headerRef = React.useRef(null) + + const {data: preferences} = usePreferencesQuery() + const { + isFetching, + isError: isThreadError, + error: threadError, + refetch, + data: {thread, threadgate} = {}, + dataUpdatedAt: fetchedAt, + } = usePostThreadQuery(uri) + + // The original source of truth for these are the server settings. + const serverPrefs = preferences?.threadViewPrefs + const serverPrioritizeFollowedUsers = + serverPrefs?.prioritizeFollowedUsers ?? true + const serverTreeViewEnabled = serverPrefs?.lab_treeViewEnabled ?? false + const serverSortReplies = serverPrefs?.sort ?? 'hotness' + + // However, we also need these to work locally for PWI (without persistence). + // So we're mirroring them locally. + const prioritizeFollowedUsers = serverPrioritizeFollowedUsers + const [treeViewEnabled, setTreeViewEnabled] = useState(serverTreeViewEnabled) + const [sortReplies, setSortReplies] = useState(serverSortReplies) + + // We'll reset the local state if new server state flows down to us. + const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs) + if (prevServerPrefs !== serverPrefs) { + setPrevServerPrefs(serverPrefs) + setTreeViewEnabled(serverTreeViewEnabled) + setSortReplies(serverSortReplies) + } + + // And we'll update the local state when mutating the server prefs. + const {mutate: mutateThreadViewPrefs} = useSetThreadViewPreferencesMutation() + function updateTreeViewEnabled(newTreeViewEnabled: boolean) { + setTreeViewEnabled(newTreeViewEnabled) + if (hasSession) { + mutateThreadViewPrefs({lab_treeViewEnabled: newTreeViewEnabled}) + } + } + function updateSortReplies(newSortReplies: string) { + setSortReplies(newSortReplies) + if (hasSession) { + mutateThreadViewPrefs({sort: newSortReplies}) + } + } + + const treeView = React.useMemo( + () => treeViewEnabled && hasBranchingReplies(thread), + [treeViewEnabled, thread], + ) + + const rootPost = thread?.type === 'post' ? thread.post : undefined + const rootPostRecord = thread?.type === 'post' ? thread.record : undefined + const threadgateRecord = threadgate?.record as + | AppBskyFeedThreadgate.Record + | undefined + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ + threadgateRecord, + }) + + const moderationOpts = useModerationOpts() + const isNoPwi = React.useMemo(() => { + const mod = + rootPost && moderationOpts + ? moderatePost(rootPost, moderationOpts) + : undefined + return !!mod + ?.ui('contentList') + .blurs.find( + cause => + cause.type === 'label' && + cause.labelDef.identifier === '!no-unauthenticated', + ) + }, [rootPost, moderationOpts]) + + // Values used for proper rendering of parents + const ref = useRef(null) + const highlightedPostRef = useRef(null) + const [maxParents, setMaxParents] = React.useState( + isWeb ? Infinity : PARENTS_CHUNK_SIZE, + ) + const [maxReplies, setMaxReplies] = React.useState(50) + + useSetTitle( + rootPost && !isNoPwi + ? `${sanitizeDisplayName( + rootPost.author.displayName || `@${rootPost.author.handle}`, + )}: "${rootPostRecord!.text}"` + : '', + ) + + // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed. + // This ensures that the first render contains no parents--even if they are already available in the cache. + // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen. + // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead. + const [deferParents, setDeferParents] = React.useState(isNative) + + const currentDid = currentAccount?.did + const threadModerationCache = React.useMemo(() => { + const cache: ThreadModerationCache = new WeakMap() + if (thread && moderationOpts) { + fillThreadModerationCache(cache, thread, moderationOpts) + } + return cache + }, [thread, moderationOpts]) + + const [justPostedUris, setJustPostedUris] = React.useState( + () => new Set(), + ) + + const [fetchedAtCache] = React.useState(() => new Map()) + const [randomCache] = React.useState(() => new Map()) + const skeleton = React.useMemo(() => { + if (!thread) return null + return createThreadSkeleton( + sortThread( + thread, + { + // Prefer local state as the source of truth. + sort: sortReplies, + lab_treeViewEnabled: treeViewEnabled, + prioritizeFollowedUsers, + }, + threadModerationCache, + currentDid, + justPostedUris, + threadgateHiddenReplies, + fetchedAtCache, + fetchedAt, + randomCache, + ), + currentDid, + treeView, + threadModerationCache, + hiddenRepliesState !== HiddenRepliesState.Hide, + threadgateHiddenReplies, + ) + }, [ + thread, + prioritizeFollowedUsers, + sortReplies, + treeViewEnabled, + currentDid, + treeView, + threadModerationCache, + hiddenRepliesState, + justPostedUris, + threadgateHiddenReplies, + fetchedAtCache, + fetchedAt, + randomCache, + ]) + console.log({thread, skeleton}) + + const error = React.useMemo(() => { + if (AppBskyFeedDefs.isNotFoundPost(thread)) { + return { + title: _(msg`Post not found`), + message: _(msg`The post may have been deleted.`), + } + } else if (skeleton?.highlightedPost.type === 'blocked') { + return { + title: _(msg`Post hidden`), + message: _( + msg`You have blocked the author or you have been blocked by the author.`, + ), + } + } else if (threadError?.message.startsWith('Post not found')) { + return { + title: _(msg`Post not found`), + message: _(msg`The post may have been deleted.`), + } + } else if (isThreadError) { + return { + message: threadError ? cleanError(threadError) : undefined, + } + } + + return null + }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) + + // construct content + const posts = React.useMemo(() => { + if (!skeleton) return [] + + const {parents, highlightedPost, replies} = skeleton + let arr: RowItem[] = [] + if (highlightedPost.type === 'post') { + // We want to wait for parents to load before rendering. + // If you add something here, you'll need to update both + // maintainVisibleContentPosition and onContentSizeChange + // to "hold onto" the correct row instead of the first one. + + if (!highlightedPost.ctx.isParentLoading && !deferParents) { + // When progressively revealing parents, rendering a placeholder + // here will cause scrolling jumps. Don't add it unless you test it. + // QT'ing this thread is a great way to test all the scrolling hacks: + // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o + + // Everything is loaded + let startIndex = Math.max(0, parents.length - maxParents) + for (let i = startIndex; i < parents.length; i++) { + arr.push(parents[i]) + } + } + arr.push(highlightedPost) + if (!highlightedPost.post.viewer?.replyDisabled) { + arr.push(REPLY_PROMPT) + } + for (let i = 0; i < replies.length; i++) { + arr.push(replies[i]) + if (i === maxReplies) { + break + } + } + } + return arr + }, [skeleton, deferParents, maxParents, maxReplies]) + + // This is only used on the web to keep the post in view when its parents load. + // On native, we rely on `maintainVisibleContentPosition` instead. + const didAdjustScrollWeb = useRef(false) + const onContentSizeChangeWeb = React.useCallback(() => { + // only run once + if (didAdjustScrollWeb.current) { + return + } + // wait for loading to finish + if (thread?.type === 'post' && !!thread.parent) { + // Measure synchronously to avoid a layout jump. + const postNode = highlightedPostRef.current + const headerNode = headerRef.current + if (postNode && headerNode) { + let pageY = (postNode as any as Element).getBoundingClientRect().top + pageY -= (headerNode as any as Element).getBoundingClientRect().height + pageY = Math.max(0, pageY) + ref.current?.scrollToOffset({ + animated: false, + offset: pageY, + }) + } + didAdjustScrollWeb.current = true + } + }, [thread]) + + // On native, we reveal parents in chunks. Although they're all already + // loaded and FlatList already has its own virtualization, unfortunately FlatList + // has a bug that causes the content to jump around if too many items are getting + // prepended at once. It also jumps around if items get prepended during scroll. + // To work around this, we prepend rows after scroll bumps against the top and rests. + const needsBumpMaxParents = React.useRef(false) + const onStartReached = React.useCallback(() => { + if (skeleton?.parents && maxParents < skeleton.parents.length) { + needsBumpMaxParents.current = true + } + }, [maxParents, skeleton?.parents]) + const bumpMaxParentsIfNeeded = React.useCallback(() => { + if (!isNative) { + return + } + if (needsBumpMaxParents.current) { + needsBumpMaxParents.current = false + setMaxParents(n => n + PARENTS_CHUNK_SIZE) + } + }, []) + const onScrollToTop = bumpMaxParentsIfNeeded + const onMomentumEnd = React.useCallback(() => { + 'worklet' + runOnJS(bumpMaxParentsIfNeeded)() + }, [bumpMaxParentsIfNeeded]) + + const onEndReached = React.useCallback(() => { + if (isFetching || posts.length < maxReplies) return + setMaxReplies(prev => prev + 50) + }, [isFetching, maxReplies, posts.length]) + + const onPostReply = React.useCallback( + (postUri: string | undefined) => { + refetch() + if (postUri) { + setJustPostedUris(set => { + const nextSet = new Set(set) + nextSet.add(postUri) + return nextSet + }) + } + }, + [refetch], + ) + + const {openComposer} = useComposerControls() + const onPressReply = React.useCallback(() => { + if (thread?.type !== 'post') { + return + } + openComposer({ + replyTo: { + uri: thread.post.uri, + cid: thread.post.cid, + text: thread.record.text, + author: thread.post.author, + embed: thread.post.embed, + moderation: threadModerationCache.get(thread), + }, + onPost: onPostReply, + }) + }, [openComposer, thread, onPostReply, threadModerationCache]) + + const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled + const hasParents = + skeleton?.highlightedPost?.type === 'post' && + (skeleton.highlightedPost.ctx.isParentLoading || + Boolean(skeleton?.parents && skeleton.parents.length > 0)) + + const renderItem = ({item, index}: {item: RowItem; index: number}) => { + if (item === REPLY_PROMPT && hasSession) { + return ( + + {!isMobile && ( + + )} + + ) + } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { + return ( + + setHiddenRepliesState( + item === SHOW_HIDDEN_REPLIES + ? HiddenRepliesState.Show + : HiddenRepliesState.ShowAndOverridePostHider, + ) + } + hideTopBorder={index === 0} + /> + ) + } else if (isThreadNotFound(item)) { + return ( + + + Deleted post. + + + ) + } else if (isThreadBlocked(item)) { + return ( + + + Blocked post. + + + ) + } else if (isThreadPost(item)) { + const prev = isThreadPost(posts[index - 1]) + ? (posts[index - 1] as ThreadPost) + : undefined + const next = isThreadPost(posts[index + 1]) + ? (posts[index + 1] as ThreadPost) + : undefined + const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth + const showParentReplyLine = + (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1 + const hasUnrevealedParents = + index === 0 && skeleton?.parents && maxParents < skeleton.parents.length + + if (!treeView && prev && item.ctx.hasMoreSelfThread) { + return + } + + return ( + setDeferParents(false) : undefined}> + 0 + } + onPostReply={onPostReply} + hideTopBorder={index === 0 && !item.ctx.isParentLoading} + /> + + ) + } + return null + } + + if (!thread || !preferences || error) { + return ( + + ) + } + + return ( + <> + + + + + Post + + + + + + + + + + } + initialNumToRender={initialNumToRender} + windowSize={11} + sideBorders={false} + /> + + {isMobile && canReply && hasSession && ( + + )} + + ) +} + +let ThreadMenu = ({ + sortReplies, + treeViewEnabled, + setSortReplies, + setTreeViewEnabled, +}: { + sortReplies: string + treeViewEnabled: boolean + setSortReplies: (newValue: string) => void + setTreeViewEnabled: (newValue: boolean) => void +}): React.ReactNode => { + const {_} = useLingui() + return ( + + + {({props}) => ( + + )} + + + + Show replies as + + + { + setTreeViewEnabled(false) + }}> + + Linear + + + + { + setTreeViewEnabled(true) + }}> + + Threaded + + + + + + + Reply sorting + + + { + setSortReplies('hotness') + }}> + + Hot replies first + + + + { + setSortReplies('oldest') + }}> + + Oldest replies first + + + + { + setSortReplies('newest') + }}> + + Newest replies first + + + + { + setSortReplies('most-likes') + }}> + + Most-liked replies first + + + + { + setSortReplies('random') + }}> + + Random (aka "Poster's Roulette") + + + + + + + ) +} +ThreadMenu = memo(ThreadMenu) + +function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { + const safeAreaInsets = useSafeAreaInsets() + const fabMinimalShellTransform = useMinimalShellFabTransform() + return ( + + + + ) +} + +function isThreadPost(v: unknown): v is ThreadPost { + return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' +} + +function isThreadNotFound(v: unknown): v is ThreadNotFound { + return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found' +} + +function isThreadBlocked(v: unknown): v is ThreadBlocked { + return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked' +} + +function createThreadSkeleton( + node: ThreadNode, + currentDid: string | undefined, + treeView: boolean, + modCache: ThreadModerationCache, + showHiddenReplies: boolean, + threadgateRecordHiddenReplies: Set, +): ThreadSkeletonParts | null { + if (!node) return null + + return { + parents: Array.from(flattenThreadParents(node, !!currentDid)), + highlightedPost: node, + replies: Array.from( + flattenThreadReplies( + node, + currentDid, + treeView, + modCache, + showHiddenReplies, + threadgateRecordHiddenReplies, + ), + ), + } +} + +function* flattenThreadParents( + node: ThreadNode, + hasSession: boolean, +): Generator { + if (node.type === 'post') { + if (node.parent) { + yield* flattenThreadParents(node.parent, hasSession) + } + if (!node.ctx.isHighlightedPost) { + yield node + } + } else if (node.type === 'not-found') { + yield node + } else if (node.type === 'blocked') { + yield node + } +} + +// The enum is ordered to make them easy to merge +enum HiddenReplyType { + None = 0, + Muted = 1, + Hidden = 2, +} + +function* flattenThreadReplies( + node: ThreadNode, + currentDid: string | undefined, + treeView: boolean, + modCache: ThreadModerationCache, + showHiddenReplies: boolean, + threadgateRecordHiddenReplies: Set, +): Generator { + if (node.type === 'post') { + // dont show pwi-opted-out posts to logged out users + if (!currentDid && hasPwiOptOut(node)) { + return HiddenReplyType.None + } + + // handle blurred items + if (node.ctx.depth > 0) { + const modui = modCache.get(node)?.ui('contentList') + if (modui?.blur || modui?.filter) { + if (!showHiddenReplies || node.ctx.depth > 1) { + if ((modui.blurs[0] || modui.filters[0]).type === 'muted') { + return HiddenReplyType.Muted + } + return HiddenReplyType.Hidden + } + } + + if (!showHiddenReplies) { + const hiddenByThreadgate = threadgateRecordHiddenReplies.has( + node.post.uri, + ) + const authorIsViewer = node.post.author.did === currentDid + if (hiddenByThreadgate && !authorIsViewer) { + return HiddenReplyType.Hidden + } + } + } + + if (!node.ctx.isHighlightedPost) { + yield node + } + + if (node.replies?.length) { + let hiddenReplies = HiddenReplyType.None + for (const reply of node.replies) { + let hiddenReply = yield* flattenThreadReplies( + reply, + currentDid, + treeView, + modCache, + showHiddenReplies, + threadgateRecordHiddenReplies, + ) + if (hiddenReply > hiddenReplies) { + hiddenReplies = hiddenReply + } + if (!treeView && !node.ctx.isHighlightedPost) { + break + } + } + + // show control to enable hidden replies + if (node.ctx.depth === 0) { + if (hiddenReplies === HiddenReplyType.Muted) { + yield SHOW_MUTED_REPLIES + } else if (hiddenReplies === HiddenReplyType.Hidden) { + yield SHOW_HIDDEN_REPLIES + } + } + } + } else if (node.type === 'not-found') { + yield node + } else if (node.type === 'blocked') { + yield node + } + return HiddenReplyType.None +} + +function hasPwiOptOut(node: ThreadPost) { + return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') +} + +function hasBranchingReplies(node?: ThreadNode) { + if (!node) { + return false + } + if (node.type !== 'post') { + return false + } + if (!node.replies) { + return false + } + if (node.replies.length === 1) { + return hasBranchingReplies(node.replies[0]) + } + return true +} + +const styles = StyleSheet.create({ + prompt: { + // @ts-ignore web-only + position: isWeb ? 'fixed' : 'absolute', + left: 0, + right: 0, + }, +}) diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 1bad9b6cdf..078c0041b8 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,11 +1,39 @@ -import React from 'react' +import {useCallback, useMemo, useRef, useState} from 'react' +import {StyleSheet, useWindowDimensions, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyFeedDefs} from '@atproto/api' +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 {makeRecordUri} from '#/lib/strings/url-helpers' 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 {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {useGetPostThreadV2, Slice} from '#/state/queries/useGetPostThreadV2' +import * as Menu from '#/components/Menu' +import {Button, ButtonIcon} from '#/components/Button' +import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' +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 {useComposerControls} from '#/state/shell' + +const MAINTAIN_VISIBLE_CONTENT_POSITION = { + // We don't insert any elements before the root row while loading. + // So the row we want to use as the scroll anchor is the first row. + minIndexForVisible: 1, +} type Props = NativeStackScreenProps export function PostThreadScreen({route}: Props) { @@ -15,14 +43,419 @@ export function PostThreadScreen({route}: Props) { const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) useFocusEffect( - React.useCallback(() => { + useCallback(() => { setMinimalShellMode(false) }, [setMinimalShellMode]), ) return ( - + {/* */} + ) } + +function useThreadPreferences() { + const {data: preferences} = usePreferencesQuery() + const nextThreadPreferences = preferences?.threadViewPrefs + + /* + * Create local state representations of server state + */ + const [sortReplies, setSortReplies] = useState( + nextThreadPreferences?.sort ?? 'hotness', + ) + const [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState( + !!nextThreadPreferences?.prioritizeFollowedUsers, + ) + const [treeViewEnabled, setTreeViewEnabled] = useState( + !!nextThreadPreferences?.lab_treeViewEnabled, + ) + + /** + * Cache existing and if we get a server update, reset local state + */ + const [prevServerPrefs, setPrevServerPrefs] = useState(nextThreadPreferences) + if (nextThreadPreferences && prevServerPrefs !== nextThreadPreferences) { + setPrevServerPrefs(nextThreadPreferences) + + /* + * Reset + */ + setSortReplies(nextThreadPreferences.sort) + setPrioritizeFollowedUsers(nextThreadPreferences.prioritizeFollowedUsers) + setTreeViewEnabled(!!nextThreadPreferences.lab_treeViewEnabled) + } + + const isLoaded = !!prevServerPrefs + + return useMemo(() => ({ + isLoaded, + sortReplies, + setSortReplies, + prioritizeFollowedUsers, + setPrioritizeFollowedUsers, + treeViewEnabled, + setTreeViewEnabled, + }), [ + isLoaded, + sortReplies, + setSortReplies, + prioritizeFollowedUsers, + setPrioritizeFollowedUsers, + treeViewEnabled, + setTreeViewEnabled, + ]) +} + +export function Inner({uri}: {uri: string | undefined}) { + const t = useTheme() + const {_} = useLingui() + const {gtPhone} = useBreakpoints() + // const {hasSession, currentAccount} = useSession() + const initialNumToRender = useInitialNumToRender() + const {height: windowHeight} = useWindowDimensions() + + const { + isLoaded: isThreadPreferencesLoaded, + sortReplies, + setSortReplies, + prioritizeFollowedUsers, + treeViewEnabled, + setTreeViewEnabled, + } = useThreadPreferences() + + const {isFetching, isPlaceholderData, error, data, refetch} = useGetPostThreadV2({ + uri, + enabled: isThreadPreferencesLoaded, + options: { + sort: sortReplies, + view: treeViewEnabled ? 'tree' : 'linear', + prioritizeFollows: prioritizeFollowedUsers, + }, + }) + + const ref = useRef(null) + const layoutHeaderRef = useRef(null) + const anchorPostRef = useRef(null) + const anchorPost = data?.slices.find(slice => slice.type === 'threadSlice' && slice.ui.isAnchor) + // TODO + const [justPostedUris, setJustPostedUris] = useState( + () => new Set(), + ) + + const onPostReply = useCallback( + (postUri: string | undefined) => { + refetch() + if (postUri) { + setJustPostedUris(set => { + const nextSet = new Set(set) + nextSet.add(postUri) + return nextSet + }) + } + }, + [refetch], + ) + + const {openComposer} = useComposerControls() + const onReplyToAnchor = () => { + if (anchorPost?.type !== 'threadSlice') { + return + } + const post = anchorPost.slice.post + openComposer({ + replyTo: { + uri: anchorPost.slice.uri, + cid: post.cid, + text: post.record.text, + author: post.author, + embed: post.embed, + moderation: anchorPost.moderation, + }, + onPost: onPostReply, + }) + } + + const renderItem = ({item, index}: {item: Slice; index: number}) => { + if (item.type === 'threadSlice') { + return ( + + 0 + // } + onPostReply={onPostReply} + hideTopBorder={index === 0} // && !item.slice.isParentLoading} // TODO + /> + + ) + } else if (item.type === 'replyComposer') { + return ( + + {gtPhone && } + + ) + } + return null + } + + /* + * This is only used on the web to keep the post in view when its parents + * load. On native, we rely on `maintainVisibleContentPosition` instead. + * + * This runs synchronously before layout, meaning by the time the page + * paints, we've already measured and adjusted the scroll position of the + * list. + */ + const didAdjustScrollWeb = useRef(false) + const onContentSizeChangeWeb = () => { + // only run once + if (didAdjustScrollWeb.current) return + if (!isPlaceholderData) { + // Measure synchronously to avoid a layout jump. + const anchorPost = anchorPostRef.current as any as Element + const headerNode = layoutHeaderRef.current as any as Element + if (anchorPost && headerNode) { + // get new scroll position + let pageY = anchorPost.getBoundingClientRect().top + // subtract header height + pageY -= headerNode.getBoundingClientRect().height + // don't scroll past 0 + pageY = Math.max(0, pageY) + ref.current?.scrollToOffset({ + animated: false, + offset: pageY, + }) + didAdjustScrollWeb.current = true + } + } + } + + return ( + <> + + + + + Post + + + + + + + + {error ? ( + + ) : ( + + + } + initialNumToRender={initialNumToRender} + windowSize={11} + sideBorders={false} + /> + + )} + + ) +} + +function PostThreadError({error}: {error: Error}) { + const {_} = useLingui() + + // TODO use new cleanError hook + const {title, message} = useMemo(() => { + let title = _(msg`An error occurred`) + let message = cleanError(error) + + if (error.message.startsWith('Post not found')) { + title = _(msg`Post not found`) + message = _(msg`The post may have been deleted.`) + } + return {title, message} + }, [_, error]) + + return +} + +function ThreadMenu({ + sortReplies, + treeViewEnabled, + setSortReplies, + setTreeViewEnabled, +}: { + sortReplies: string + treeViewEnabled: boolean + setSortReplies: (newValue: string) => void + setTreeViewEnabled: (newValue: boolean) => void +}): React.ReactNode { + const {_} = useLingui() + return ( + + + {({props}) => ( + + )} + + + + Show replies as + + + { + setTreeViewEnabled(false) + }}> + + Linear + + + + { + setTreeViewEnabled(true) + }}> + + Threaded + + + + + + + Reply sorting + + + { + setSortReplies('hotness') + }}> + + Hot replies first + + + + { + setSortReplies('oldest') + }}> + + Oldest replies first + + + + { + setSortReplies('newest') + }}> + + Newest replies first + + + + { + setSortReplies('most-likes') + }}> + + Most-liked replies first + + + + { + setSortReplies('random') + }}> + + Random (aka "Poster's Roulette") + + + + + + + ) +} + +const keyExtractor = (item: Slice) => { + return item.key +}