From 1380c5ac67c937b84dd5d7172fbb660c45d0c87c Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 10 Jun 2025 23:29:39 +0300 Subject: [PATCH 1/5] use ref instead of source (#8471) --- src/state/unstable-post-source.tsx | 31 ++++++++++-------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/src/state/unstable-post-source.tsx b/src/state/unstable-post-source.tsx index 1fb4af2872..43aac6f4d0 100644 --- a/src/state/unstable-post-source.tsx +++ b/src/state/unstable-post-source.tsx @@ -1,4 +1,4 @@ -import {createContext, useCallback, useContext, useState} from 'react' +import {createContext, useCallback, useContext, useRef, useState} from 'react' import {type AppBskyFeedDefs} from '@atproto/api' import {type FeedDescriptor} from './queries/post-feed' @@ -22,30 +22,19 @@ const ConsumeUnstablePostSourceContext = createContext< >(() => undefined) export function Provider({children}: {children: React.ReactNode}) { - const [sources, setSources] = useState>(() => new Map()) + const sourcesRef = useRef>(new Map()) const setUnstablePostSource = useCallback((key: string, source: Source) => { - setSources(prev => { - const newMap = new Map(prev) - newMap.set(key, source) - return newMap - }) + sourcesRef.current.set(key, source) }, []) - const consumeUnstablePostSource = useCallback( - (uri: string) => { - const source = sources.get(uri) - if (source) { - setSources(prev => { - const newMap = new Map(prev) - newMap.delete(uri) - return newMap - }) - } - return source - }, - [sources], - ) + const consumeUnstablePostSource = useCallback((uri: string) => { + const source = sourcesRef.current.get(uri) + if (source) { + sourcesRef.current.delete(uri) + } + return source + }, []) return ( From 269105371b22f2de6e8017862a783aaec340948b Mon Sep 17 00:00:00 2001 From: Francisco Nascimento Date: Wed, 11 Jun 2025 14:21:31 +0100 Subject: [PATCH 2/5] Instant Feed Update on Mute or Moderation Action (#8463) * Implemented #2406: Instant Feed Update on Mute or Moderation Action Posts from muted or blocked users are now removed immediately from the feed. This is achieved by extending the usePostShadow hook to check if the post author is muted or blocked and return POST_TOMBSTONE accordingly. A unit test was also added to validate the new logic. Co-authored-by: Pedro Macedo * remove useless tests --------- Co-authored-by: Pedro Macedo Co-authored-by: Samuel Newman --- src/state/cache/post-shadow.ts | 12 ++++++++++-- src/state/queries/profile.ts | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts index 923e5c0000..3f9644879b 100644 --- a/src/state/cache/post-shadow.ts +++ b/src/state/cache/post-shadow.ts @@ -14,6 +14,7 @@ import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/qu import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread' import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' +import {useProfileShadow} from './profile-shadow' import {castAsShadow, type Shadow} from './types' export type {Shadow} from './types' @@ -43,6 +44,10 @@ export function usePostShadow( setShadow(shadows.get(post)) } + const authorShadow = useProfileShadow(post.author) + const wasMuted = !!authorShadow.viewer?.muted + const wasBlocked = !!authorShadow.viewer?.blocking + useEffect(() => { function onUpdate() { setShadow(shadows.get(post)) @@ -54,15 +59,18 @@ export function usePostShadow( }, [post, setShadow]) return useMemo(() => { + if (wasMuted || wasBlocked) { + return POST_TOMBSTONE + } if (shadow) { return mergeShadow(post, shadow) } else { return castAsShadow(post) } - }, [post, shadow]) + }, [post, shadow, wasMuted, wasBlocked]) } -function mergeShadow( +export function mergeShadow( post: AppBskyFeedDefs.PostView, shadow: Partial, ): Shadow | typeof POST_TOMBSTONE { diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index eb65fef7c2..b0af57c4a7 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -499,9 +499,10 @@ function useProfileBlockMutation() { {subject: did, createdAt: new Date().toISOString()}, ) }, - onSuccess(_, {did}) { + onSuccess(data, {did}) { queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()}) resetProfilePostsQueries(queryClient, did, 1000) + updateProfileShadow(queryClient, did, {blockingUri: data.uri}) }, }) } @@ -523,6 +524,7 @@ function useProfileUnblockMutation() { }, onSuccess(_, {did}) { resetProfilePostsQueries(queryClient, did, 1000) + updateProfileShadow(queryClient, did, {blockingUri: undefined}) }, }) } From 7341294df6156afdf24ab43e1e27ebba94f265ad Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 11 Jun 2025 20:12:05 +0300 Subject: [PATCH 3/5] Fix using screen names in `Link` (#8473) * use our router in favour of useLinkBuilder * test feature using Home header feeds button * handle non-string params properly --- src/components/Link.tsx | 8 +++----- src/lib/routes/router.ts | 8 ++++---- src/lib/routes/types.ts | 2 +- src/view/com/home/HomeHeaderLayoutMobile.tsx | 14 +++----------- 4 files changed, 11 insertions(+), 21 deletions(-) diff --git a/src/components/Link.tsx b/src/components/Link.tsx index d73a3db4ae..49c9c52358 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -4,7 +4,6 @@ import {sanitizeUrl} from '@braintree/sanitize-url' import { type LinkProps as RNLinkProps, StackActions, - useLinkBuilder, } from '@react-navigation/native' import {BSKY_DOWNLOAD_URL} from '#/lib/constants' @@ -95,20 +94,19 @@ export function useLink({ shouldProxy?: boolean }) { const navigation = useNavigationDeduped() - const {buildHref} = useLinkBuilder() const href = useMemo(() => { return typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to.screen - ? buildHref(to.screen, to.params) + ? router.matchName(to.screen)?.build(to.params) : to.href ? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href)) : undefined - }, [to, buildHref]) + }, [to]) if (!href) { throw new Error( - 'Link `to` prop must be a string or an object with `screen` and `params` properties', + 'Could not resolve screen. Link `to` prop must be a string or an object with `screen` and `params` properties', ) } diff --git a/src/lib/routes/router.ts b/src/lib/routes/router.ts index 45f9c85fdb..ba76b1bdac 100644 --- a/src/lib/routes/router.ts +++ b/src/lib/routes/router.ts @@ -1,4 +1,4 @@ -import {Route, RouteParams} from './types' +import {type Route, type RouteParams} from './types' export class Router { routes: [string, Route][] = [] @@ -45,7 +45,7 @@ function createRoute(pattern: string): Route { }) const matcherRe = new RegExp(`^${matcherReInternal}([?]|$)`, 'i') return { - match(path: string) { + match(path) { const {pathname, searchParams} = new URL(path, 'http://throwaway.com') const addedParams = Object.fromEntries(searchParams.entries()) @@ -55,10 +55,10 @@ function createRoute(pattern: string): Route { } return undefined }, - build(params: Record) { + build(params = {}) { const str = pattern.replace( /:([\w]+)/g, - (_m, name) => params[name] || 'undefined', + (_m, name) => params[encodeURIComponent(name)] || 'undefined', ) let hasQp = false diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 6f102d438a..f587423900 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -143,5 +143,5 @@ export type RouteParams = Record export type MatchResult = {params: RouteParams} export type Route = { match: (path: string) => MatchResult | undefined - build: (params: RouteParams) => string + build: (params?: Record) => string } diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index e48c2cc893..7a40604f43 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import Animated from 'react-native-reanimated' import {msg} from '@lingui/macro' @@ -56,13 +55,8 @@ export function HomeHeaderLayoutMobile({ { - emitSoftReset() - }} - onPressIn={() => { - playHaptic('Heavy') - }} - onPressOut={() => { playHaptic('Light') + emitSoftReset() }}> @@ -72,7 +66,7 @@ export function HomeHeaderLayoutMobile({ {hasSession && ( From 143d5f3b814f1ce707fdfc87dabff7af5349bd06 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 11 Jun 2025 13:22:02 -0500 Subject: [PATCH 4/5] Post source handling updates (#8472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add debugs * Key post-source using URI with handle * Enhance * EnHANCE * ENHANCE * ENHANCEEEECEE * ᵉⁿʰᵃⁿᶜᵉ * enhance --- src/App.native.tsx | 19 ++- src/App.web.tsx | 11 +- src/logger/types.ts | 2 + src/state/feed-feedback.tsx | 8 +- src/state/unstable-post-source.tsx | 121 +++++++++++++------- src/view/com/post-thread/PostThread.tsx | 30 ++++- src/view/com/post-thread/PostThreadItem.tsx | 34 +++--- src/view/com/posts/PostFeedItem.tsx | 8 +- 8 files changed, 148 insertions(+), 85 deletions(-) diff --git a/src/App.native.tsx b/src/App.native.tsx index e3f85c0fe5..baab8c8385 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -58,7 +58,6 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' -import {Provider as UnstablePostSourceProvider} from '#/state/unstable-post-source' import {TestCtrls} from '#/view/com/testing/TestCtrls' import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' @@ -151,16 +150,14 @@ function InnerApp() { - - - - - - - - - + + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 97ada61485..c5ec0473ce 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -48,7 +48,6 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' -import {Provider as UnstablePostSourceProvider} from '#/state/unstable-post-source' import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' @@ -132,12 +131,10 @@ function InnerApp() { - - - - - - + + + + diff --git a/src/logger/types.ts b/src/logger/types.ts index d14e21a9d4..88d8d9d93d 100644 --- a/src/logger/types.ts +++ b/src/logger/types.ts @@ -10,6 +10,8 @@ export enum LogContext { ConversationAgent = 'conversation-agent', DMsAgent = 'dms-agent', ReportDialog = 'report-dialog', + FeedFeedback = 'feed-feedback', + PostSource = 'post-source', /** * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index 225b495d3f..a718a761d5 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -12,7 +12,7 @@ import throttle from 'lodash.throttle' import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants' import {logEvent} from '#/lib/statsig/statsig' -import {logger} from '#/logger' +import {Logger} from '#/logger' import { type FeedDescriptor, type FeedPostSliceItem, @@ -20,6 +20,8 @@ import { import {getItemsForFeedback} from '#/view/com/posts/PostFeed' import {useAgent} from './session' +const logger = Logger.create(Logger.Context.FeedFeedback) + export type StateContext = { enabled: boolean onItemSeen: (item: any) => void @@ -89,6 +91,7 @@ export function useFeedFeedback( } sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions) throttledFlushAggregatedStats() + logger.debug('flushed') }, [agent, throttledFlushAggregatedStats, feed]) const sendToFeed = useMemo( @@ -141,6 +144,9 @@ export function useFeedFeedback( if (!enabled) { return } + logger.debug('sendInteraction', { + ...interaction, + }) if (!history.current.has(interaction)) { history.current.add(interaction) queue.current.add(toString(interaction)) diff --git a/src/state/unstable-post-source.tsx b/src/state/unstable-post-source.tsx index 43aac6f4d0..ac126d79c6 100644 --- a/src/state/unstable-post-source.tsx +++ b/src/state/unstable-post-source.tsx @@ -1,62 +1,97 @@ -import {createContext, useCallback, useContext, useRef, useState} from 'react' -import {type AppBskyFeedDefs} from '@atproto/api' +import {useEffect, useId, useState} from 'react' +import {type AppBskyFeedDefs, AtUri} from '@atproto/api' -import {type FeedDescriptor} from './queries/post-feed' +import {Logger} from '#/logger' +import {type FeedDescriptor} from '#/state/queries/post-feed' /** - * For passing the source of the post (i.e. the original post, from the feed) to the threadview, - * without using query params. Deliberately unstable to avoid using query params, use for FeedFeedback - * and other ephemeral non-critical systems. + * Separate logger for better debugging */ +const logger = Logger.create(Logger.Context.PostSource) -type Source = { +export type PostSource = { post: AppBskyFeedDefs.FeedViewPost feed?: FeedDescriptor } -const SetUnstablePostSourceContext = createContext< - (key: string, source: Source) => void ->(() => {}) -const ConsumeUnstablePostSourceContext = createContext< - (uri: string) => Source | undefined ->(() => undefined) +/** + * A cache of sources that will be consumed by the post thread view. This is + * cleaned up any time a source is consumed. + */ +const transientSources = new Map() -export function Provider({children}: {children: React.ReactNode}) { - const sourcesRef = useRef>(new Map()) +/** + * A cache of sources that have been consumed by the post thread view. This is + * not cleaned up, but because we use a new ID for each post thread view that + * consumes a source, this is never reused unless a user navigates back to a + * post thread view that has not been dropped from memory. + */ +const consumedSources = new Map() - const setUnstablePostSource = useCallback((key: string, source: Source) => { - sourcesRef.current.set(key, source) - }, []) - - const consumeUnstablePostSource = useCallback((uri: string) => { - const source = sourcesRef.current.get(uri) - if (source) { - sourcesRef.current.delete(uri) - } - return source - }, []) - - return ( - - - {children} - - +/** + * For stashing the feed that the user was browsing when they clicked on a post. + * + * Used for FeedFeedback and other ephemeral non-critical systems. + */ +export function setUnstablePostSource(key: string, source: PostSource) { + assertValid( + key, + `setUnstablePostSource key should be a URI containing a handle, received ${key} — use buildPostSourceKey`, ) -} - -export function useSetUnstablePostSource() { - return useContext(SetUnstablePostSourceContext) + logger.debug('set', {key, source}) + transientSources.set(key, source) } /** - * DANGER - This hook is unstable and should only be used for FeedFeedback - * and other ephemeral non-critical systems. Does not change when the URI changes. + * This hook is unstable and should only be used for FeedFeedback and other + * ephemeral non-critical systems. Views that use this hook will continue to + * return a reference to the same source until those views are dropped from + * memory. */ -export function useUnstablePostSource(uri: string) { - const consume = useContext(ConsumeUnstablePostSourceContext) +export function useUnstablePostSource(key: string) { + const id = useId() + const [source] = useState(() => { + assertValid( + key, + `consumeUnstablePostSource key should be a URI containing a handle, received ${key} — use buildPostSourceKey`, + ) + const source = consumedSources.get(id) || transientSources.get(key) + if (source) { + logger.debug('consume', {id, key, source}) + transientSources.delete(key) + consumedSources.set(id, source) + } + return source + }) + + useEffect(() => { + return () => { + consumedSources.delete(id) + logger.debug('cleanup', {id}) + } + }, [id]) - const [source] = useState(() => consume(uri)) return source } + +/** + * Builds a post source key. This (atm) is a URI where the `host` is the post + * author's handle, not DID. + */ +export function buildPostSourceKey(key: string, handle: string) { + const urip = new AtUri(key) + urip.host = handle + return urip.toString() +} + +/** + * Just a lil dev helper + */ +function assertValid(key: string, message: string) { + if (__DEV__) { + const urip = new AtUri(key) + if (urip.host.startsWith('did:')) { + throw new Error(message) + } + } +} diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index d974ce6b53..5bec9ced1a 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -22,6 +22,7 @@ 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 {useFeedFeedback} from '#/state/feed-feedback' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { fillThreadModerationCache, @@ -37,6 +38,7 @@ import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences' import {useSession} from '#/state/session' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {useUnstablePostSource} from '#/state/unstable-post-source' import {List, type ListMethods} from '#/view/com/util/List' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' @@ -93,7 +95,7 @@ const keyExtractor = (item: RowItem) => { return item._reactKey } -export function PostThread({uri}: {uri: string | undefined}) { +export function PostThread({uri}: {uri: string}) { const {hasSession, currentAccount} = useSession() const {_} = useLingui() const t = useTheme() @@ -104,6 +106,8 @@ export function PostThread({uri}: {uri: string | undefined}) { HiddenRepliesState.Hide, ) const headerRef = React.useRef(null) + const anchorPostSource = useUnstablePostSource(uri) + const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) const {data: preferences} = usePreferencesQuery() const { @@ -395,10 +399,18 @@ export function PostThread({uri}: {uri: string | undefined}) { ) const {openComposer} = useOpenComposer() - const onPressReply = React.useCallback(() => { + const onReplyToAnchor = React.useCallback(() => { if (thread?.type !== 'post') { return } + if (anchorPostSource) { + feedFeedback.sendInteraction({ + item: thread.post.uri, + event: 'app.bsky.feed.defs#interactionReply', + feedContext: anchorPostSource.post.feedContext, + reqId: anchorPostSource.post.reqId, + }) + } openComposer({ replyTo: { uri: thread.post.uri, @@ -410,7 +422,14 @@ export function PostThread({uri}: {uri: string | undefined}) { }, onPost: onPostReply, }) - }, [openComposer, thread, onPostReply, threadModerationCache]) + }, [ + openComposer, + thread, + onPostReply, + threadModerationCache, + anchorPostSource, + feedFeedback, + ]) const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled const hasParents = @@ -423,7 +442,7 @@ export function PostThread({uri}: {uri: string | undefined}) { return ( {!isMobile && ( - + )} ) @@ -511,6 +530,7 @@ export function PostThread({uri}: {uri: string | undefined}) { } onPostReply={onPostReply} hideTopBorder={index === 0 && !item.ctx.isParentLoading} + anchorPostSource={anchorPostSource} /> ) @@ -586,7 +606,7 @@ export function PostThread({uri}: {uri: string | undefined}) { /> {isMobile && canReply && hasSession && ( - + )} ) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 8b39072ba6..576b195a06 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -40,7 +40,7 @@ import {useLanguagePrefs} from '#/state/preferences' import {type ThreadPost} from '#/state/queries/post-thread' import {useSession} from '#/state/session' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' -import {useUnstablePostSource} from '#/state/unstable-post-source' +import {type PostSource} from '#/state/unstable-post-source' import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {Link, TextLink} from '#/view/com/util/Link' @@ -87,6 +87,7 @@ export function PostThreadItem({ onPostReply, hideTopBorder, threadgateRecord, + anchorPostSource, }: { post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record @@ -104,6 +105,7 @@ export function PostThreadItem({ onPostReply: (postUri: string | undefined) => void hideTopBorder?: boolean threadgateRecord?: AppBskyFeedThreadgate.Record + anchorPostSource?: PostSource }) { const postShadowed = usePostShadow(post) const richText = useMemo( @@ -139,6 +141,7 @@ export function PostThreadItem({ onPostReply={onPostReply} hideTopBorder={hideTopBorder} threadgateRecord={threadgateRecord} + anchorPostSource={anchorPostSource} /> ) } @@ -184,6 +187,7 @@ let PostThreadItemLoaded = ({ onPostReply, hideTopBorder, threadgateRecord, + anchorPostSource, }: { post: Shadow record: AppBskyFeedPost.Record @@ -202,10 +206,10 @@ let PostThreadItemLoaded = ({ onPostReply: (postUri: string | undefined) => void hideTopBorder?: boolean threadgateRecord?: AppBskyFeedThreadgate.Record + anchorPostSource?: PostSource }): React.ReactNode => { const {currentAccount, hasSession} = useSession() - const source = useUnstablePostSource(post.uri) - const feedFeedback = useFeedFeedback(source?.feed, hasSession) + const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) const t = useTheme() const pal = usePalette('default') @@ -276,12 +280,12 @@ let PostThreadItemLoaded = ({ ) const onPressReply = () => { - if (source) { + if (anchorPostSource && isHighlightedPost) { feedFeedback.sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionReply', - feedContext: source.post.feedContext, - reqId: source.post.reqId, + feedContext: anchorPostSource.post.feedContext, + reqId: anchorPostSource.post.reqId, }) } openComposer({ @@ -298,23 +302,23 @@ let PostThreadItemLoaded = ({ } const onOpenAuthor = () => { - if (source) { + if (anchorPostSource) { feedFeedback.sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#clickthroughAuthor', - feedContext: source.post.feedContext, - reqId: source.post.reqId, + feedContext: anchorPostSource.post.feedContext, + reqId: anchorPostSource.post.reqId, }) } } const onOpenEmbed = () => { - if (source) { + if (anchorPostSource) { feedFeedback.sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#clickthroughEmbed', - feedContext: source.post.feedContext, - reqId: source.post.reqId, + feedContext: anchorPostSource.post.feedContext, + reqId: anchorPostSource.post.reqId, }) } } @@ -325,7 +329,7 @@ let PostThreadItemLoaded = ({ const {isActive: live} = useActorStatus(post.author) - const reason = source?.post.reason + const reason = anchorPostSource?.post.reason const viaRepost = useMemo(() => { if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { return { @@ -550,8 +554,8 @@ let PostThreadItemLoaded = ({ onPostReply={onPostReply} logContext="PostThreadItem" threadgateRecord={threadgateRecord} - feedContext={source?.post?.feedContext} - reqId={source?.post?.reqId} + feedContext={anchorPostSource?.post?.feedContext} + reqId={anchorPostSource?.post?.reqId} viaRepost={viaRepost} /> diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index b9aa676737..fd0d1c707d 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -36,7 +36,10 @@ import {useFeedFeedbackContext} from '#/state/feed-feedback' import {unstableCacheProfileView} from '#/state/queries/profile' import {useSession} from '#/state/session' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' -import {useSetUnstablePostSource} from '#/state/unstable-post-source' +import { + buildPostSourceKey, + setUnstablePostSource, +} from '#/state/unstable-post-source' import {FeedNameText} from '#/view/com/util/FeedInfoText' import {Link, TextLink, TextLinkOnWebOnly} from '#/view/com/util/Link' import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' @@ -176,7 +179,6 @@ let FeedItemInner = ({ return makeProfileLink(post.author, 'post', urip.rkey) }, [post.uri, post.author]) const {sendInteraction, feedDescriptor} = useFeedFeedbackContext() - const unstableSetPostSource = useSetUnstablePostSource() const onPressReply = () => { sendInteraction({ @@ -232,7 +234,7 @@ let FeedItemInner = ({ reqId, }) unstableCacheProfileView(queryClient, post.author) - unstableSetPostSource(post.uri, { + setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { feed: feedDescriptor, post: { post, From 61004b887b0c7515837e051144b694fc7db5a1cc Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 11 Jun 2025 14:32:14 -0500 Subject: [PATCH 5/5] [Threads V2] Preliminary integration of unspecced V2 APIs (#8443) * WIP * Sorting working * Rough handling of hidden/muted * Better muted/hidden sorting and handling * Clarify some naming * Fix parents * Handle first reply under highlighted/composer * WIP RaW * WIP optimistic * Optimistic WIP * Little cleanup, inserting dupes * Re-org * Add in new optimistic insert logic * Update types * Sorta working linear view optimistic state * Simple working version, no pref for OP * Working optimistic reply insertions, preference for OP * Ensure deletes are coming through * WIP scroll handling * WIP scroll tweaks * Clean up scrolling * Clean up onPostSuccess * Add annotations * Fix highlighted post calc * WIP kill me * Update APIs * Nvm don't kill me * Fix optimistic insert * Handle read more cases in tree view * Basically working read more * Handle linear view * Reorg * More reorg * Split up thread post components * New reply tree layout * Fix up traversal metadata * Tighten some spacing * Use indent ya idiot * Some linear mode cleanup * Fix lines on read more items * Vibe coding to success * Almost there with read mores * Update APIs * Bump sdk * Update import * Checkpoint new traversal * Checkpoint cleanup * Checkpoint, need to fix blocked posts * Checkpoint: think we're good, needs more cleanup * Clean it up * Two passes only * Set to default params, update comment * Fix render bug on native * Checkpoint parent rendering, can opt for slower handling here * Clean up parent handling, reply handling * Fix read more extra space * Fix read more in linear view * Fix hidden reply handling, seen count, before/after calc * Update naming * Rename Slice to ThreadItem * Add basic post and anchor skeletons * Refactor client-side hidden * WIP hidden fetching * Update types * Clean up query a bit * Scrolling still broken * Ok maybe fix scrolling * Checkpoint move state into meta query * Don't load remote hidden items unless needed * skeleton view * Reset hidden items when params change * Split up traversal and avoid multiple passes * Clean up * Checkpoint: handling exhausted replies * Clean up traversal functions further * Clean up pagination * Limit optimistic reply depth * Handle optimistic insert in hidden replies * Share root query key for easier cache extraction * Make blurred posts not look like ass * Fix double deleted item * Make optimistic deleted state not look like crap in tree view * Fix parents traversal 4 real * Rename tree post * Make optimistic deletions of linear posts not look bad * Rename linear post components * Handle tombstone views * Rename read more component * Add moreParents handling * Align interaction states of read more * Fix read more on FF * Tree view skeleton * Reply composer skele * Remove hack for showing more replies * Checkpoint: sort change scrolling fixed * Checkpoint: learned new things, reset to base * Feature gate * Rename * Replace show more * Update settings screen * Update pkg and endpoint * Remove console * Eureka * Cleanup last commit * No tests atm * Remove scroll provider * Clean up callbacks, better error state * Remove todo * Remove todo * Remove todos * Format * Ok I think scrolling is solid * Add back mobile compose input * Ok need to compute headerHeight every time * Update comments * Ok button up web too * Threads v2 tweaks (#8467) * fix error screen collapsing * use personx icon for blocked posts * Remove height/width * Revert unused Header change * Clarify code * Relate consts to theme values * Remove debug code * Typo * Fix debounce of threads prefs * Update metadata comments, dev mode * Missed a spot * Clean up todo * Fix up no-unauthenticated posts * Truncate parents if no-unauth * Update getBranch docs * Remove debug code * Expand fetching in some cases * Clear scroll need for root post to fix jump bug * Fix reply composer skeleton state * Remove uneeded initialized value * Add profile shadow cache * Some metrics * prettier tweak * eslint ignore * Fix optimistic insertion * Typo * Rename, comment * Remove wait * Counter naming * Replies seen counter for moderated sub-trees * Remove borders on skeleton * Align tombstone with optimistic deletion state * Fix optimistic deletion for thread * Add tree view icon * Rename * Cleanup * Update settings copy * Header menu open metric * Bump package * Better reply prompt (#8474) * restyle reply prompt * hide bottom bar border for cleaner look * use new border hiding hook in DMs * create `transparentifyColor` function * adjust padding * fix padding in immersive lpayer * Apply suggestions from code review Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Integrate post-source (cherry picked from commit fe053e9b38395a4fcb30a4367bc800f64ea84fe9) --------- Co-authored-by: Samuel Newman Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> --- ...arrowTopCircle_stroke2_corner0_rounded.svg | 1 + .../circlePlus_stroke2_corner0_rounded.svg | 1 + assets/icons/tree_stroke2_corner0_rounded.svg | 1 + package.json | 2 +- src/App.native.tsx | 19 +- src/App.web.tsx | 11 +- src/alf/atoms.ts | 4 + src/alf/util/__tests__/colors.test.ts | 48 ++ src/alf/util/colorGeneration.ts | 28 + src/components/Skeleton.tsx | 107 +++ src/components/icons/ArrowTopCircle.tsx | 5 + src/components/icons/CirclePlus.tsx | 5 + src/components/icons/Tree.tsx | 5 + src/lib/async/retry.ts | 13 +- src/lib/hooks/useCallOnce.ts | 20 + src/lib/hooks/useHideBottomBarBorder.tsx | 50 ++ src/lib/statsig/gates.ts | 1 + src/logger/metrics.ts | 9 + .../Messages/components/MessagesList.tsx | 3 + .../PostThread/components/HeaderDropdown.tsx | 106 +++ .../PostThread/components/ThreadError.tsx | 89 +++ .../components/ThreadItemAnchor.tsx | 706 ++++++++++++++++++ .../ThreadItemAnchorNoUnauthenticated.tsx | 32 + .../PostThread/components/ThreadItemPost.tsx | 405 ++++++++++ .../ThreadItemPostNoUnauthenticated.tsx | 74 ++ .../components/ThreadItemPostTombstone.tsx | 55 ++ .../components/ThreadItemReadMore.tsx | 107 +++ .../components/ThreadItemReadMoreUp.tsx | 89 +++ .../components/ThreadItemReplyComposer.tsx | 31 + .../components/ThreadItemShowOtherReplies.tsx | 59 ++ .../components/ThreadItemTreePost.tsx | 456 +++++++++++ src/screens/PostThread/const.ts | 7 + src/screens/PostThread/index.tsx | 577 ++++++++++++++ src/screens/Settings/ThreadPreferences.tsx | 136 +++- src/screens/VideoFeed/index.tsx | 5 +- src/state/cache/post-shadow.ts | 4 + src/state/cache/profile-shadow.ts | 2 + .../preferences/useThreadPreferences.ts | 179 +++++ src/state/queries/usePostThread/const.ts | 27 + src/state/queries/usePostThread/index.ts | 325 ++++++++ src/state/queries/usePostThread/queryCache.ts | 300 ++++++++ src/state/queries/usePostThread/traversal.ts | 539 +++++++++++++ src/state/queries/usePostThread/types.ts | 227 ++++++ src/state/queries/usePostThread/utils.ts | 170 +++++ src/state/queries/usePostThread/views.ts | 183 +++++ src/state/shell/composer/index.tsx | 9 + src/state/threadgate-hidden-replies.tsx | 14 + src/storage/hooks/dev-mode.ts | 14 + src/types/utils.ts | 5 + src/view/com/composer/Composer.tsx | 58 +- src/view/com/post-thread/PostThread.tsx | 44 +- .../post-thread/PostThreadComposePrompt.tsx | 76 +- src/view/com/post-thread/PostThreadItem.tsx | 7 + src/view/screens/PostThread.tsx | 18 +- src/view/shell/Composer.ios.tsx | 1 + src/view/shell/Composer.tsx | 1 + src/view/shell/Composer.web.tsx | 1 + src/view/shell/bottom-bar/BottomBar.tsx | 4 +- src/view/shell/bottom-bar/BottomBarWeb.tsx | 13 +- yarn.lock | 14 + 60 files changed, 5416 insertions(+), 86 deletions(-) create mode 100644 assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg create mode 100644 assets/icons/circlePlus_stroke2_corner0_rounded.svg create mode 100644 assets/icons/tree_stroke2_corner0_rounded.svg create mode 100644 src/alf/util/__tests__/colors.test.ts create mode 100644 src/components/Skeleton.tsx create mode 100644 src/components/icons/ArrowTopCircle.tsx create mode 100644 src/components/icons/CirclePlus.tsx create mode 100644 src/components/icons/Tree.tsx create mode 100644 src/lib/hooks/useCallOnce.ts create mode 100644 src/lib/hooks/useHideBottomBarBorder.tsx create mode 100644 src/screens/PostThread/components/HeaderDropdown.tsx create mode 100644 src/screens/PostThread/components/ThreadError.tsx create mode 100644 src/screens/PostThread/components/ThreadItemAnchor.tsx create mode 100644 src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx create mode 100644 src/screens/PostThread/components/ThreadItemPost.tsx create mode 100644 src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx create mode 100644 src/screens/PostThread/components/ThreadItemPostTombstone.tsx create mode 100644 src/screens/PostThread/components/ThreadItemReadMore.tsx create mode 100644 src/screens/PostThread/components/ThreadItemReadMoreUp.tsx create mode 100644 src/screens/PostThread/components/ThreadItemReplyComposer.tsx create mode 100644 src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx create mode 100644 src/screens/PostThread/components/ThreadItemTreePost.tsx create mode 100644 src/screens/PostThread/const.ts create mode 100644 src/screens/PostThread/index.tsx create mode 100644 src/state/queries/preferences/useThreadPreferences.ts create mode 100644 src/state/queries/usePostThread/const.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 create mode 100644 src/types/utils.ts diff --git a/assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg b/assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..e34b5eb38f --- /dev/null +++ b/assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/circlePlus_stroke2_corner0_rounded.svg b/assets/icons/circlePlus_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..aa517a2240 --- /dev/null +++ b/assets/icons/circlePlus_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/tree_stroke2_corner0_rounded.svg b/assets/icons/tree_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..b8488c8bef --- /dev/null +++ b/assets/icons/tree_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/package.json b/package.json index ac2171a220..f0bc5bbe75 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.15.9", + "@atproto/api": "^0.15.14", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/App.native.tsx b/src/App.native.tsx index baab8c8385..25d186dcfb 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -72,6 +72,7 @@ import {Provider as PortalProvider} from '#/components/Portal' import {Splash} from '#/Splash' import {BottomSheetProvider} from '../modules/bottom-sheet' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' +import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' SplashScreen.preventAutoHideAsync() if (isIOS) { @@ -150,14 +151,16 @@ function InnerApp() { - - - - - - - + + + + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index c5ec0473ce..fa8e24e53d 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -61,6 +61,7 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as PortalProvider} from '#/components/Portal' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' +import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' /** * Begin geolocation ASAP @@ -131,10 +132,12 @@ function InnerApp() { - - - - + + + + + + diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 02ad98c5f0..79ec41679a 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -1051,4 +1051,8 @@ export const atoms = { transform: [], }, }) as {transform: Exclude}, + + pointer: web({ + cursor: 'pointer', + }), } as const diff --git a/src/alf/util/__tests__/colors.test.ts b/src/alf/util/__tests__/colors.test.ts new file mode 100644 index 0000000000..350b6ff4a4 --- /dev/null +++ b/src/alf/util/__tests__/colors.test.ts @@ -0,0 +1,48 @@ +import {jest} from '@jest/globals' + +import {logger} from '#/logger' +import {transparentifyColor} from '../colorGeneration' + +jest.mock('#/logger', () => ({ + logger: {warn: jest.fn()}, +})) + +describe('transparentifyColor', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('converts hsl() to hsla()', () => { + const result = transparentifyColor('hsl(120 100% 50%)', 0.5) + expect(result).toBe('hsla(120 100% 50%, 0.5)') + }) + + it('converts hsl() to hsla() - fully transparent', () => { + const result = transparentifyColor('hsl(120 100% 50%)', 0) + expect(result).toBe('hsla(120 100% 50%, 0)') + }) + + it('converts rgb() to rgba()', () => { + const result = transparentifyColor('rgb(255 0 0)', 0.75) + expect(result).toBe('rgba(255 0 0, 0.75)') + }) + + it('expands 3-digit hex and appends alpha channel', () => { + const result = transparentifyColor('#abc', 0.4) + expect(result).toBe('#aabbcc66') + }) + + it('appends alpha to 6-digit hex', () => { + const result = transparentifyColor('#aabbcc', 0.4) + expect(result).toBe('#aabbcc66') + }) + + it('returns the original string and warns for unsupported formats', () => { + const unsupported = 'blue' + const result = transparentifyColor(unsupported, 0.5) + expect(result).toBe(unsupported) + expect(logger.warn).toHaveBeenCalledWith( + `Could not make '${unsupported}' transparent`, + ) + }) +}) diff --git a/src/alf/util/colorGeneration.ts b/src/alf/util/colorGeneration.ts index 8d769b51b1..574ab0a496 100644 --- a/src/alf/util/colorGeneration.ts +++ b/src/alf/util/colorGeneration.ts @@ -1,3 +1,5 @@ +import {logger} from '#/logger' + export const BLUE_HUE = 211 export const RED_HUE = 346 export const GREEN_HUE = 152 @@ -19,3 +21,29 @@ export function generateScale(start: number, end: number) { export const defaultScale = generateScale(6, 100) // dim shifted 6% lighter export const dimScale = generateScale(12, 100) + +export function transparentifyColor(color: string, alpha: number) { + if (color.startsWith('hsl(')) { + return 'hsla(' + color.slice('hsl('.length, -1) + `, ${alpha})` + } else if (color.startsWith('rgb(')) { + return 'rgba(' + color.slice('rgb('.length, -1) + `, ${alpha})` + } else if (color.startsWith('#')) { + if (color.length === 7) { + const alphaHex = Math.round(alpha * 255).toString(16) + // Per MDN: If there is only one number, it is duplicated: e means ee + // https://developer.mozilla.org/en-US/docs/Web/CSS/hex-color + return color.slice(0, 7) + alphaHex.padStart(2, alphaHex) + } else if (color.length === 4) { + // convert to 6-digit hex before adding alpha + const [r, g, b] = color.slice(1).split('') + const alphaHex = Math.round(alpha * 255).toString(16) + return `#${r.repeat(2)}${g.repeat(2)}${b.repeat(2)}${alphaHex.padStart( + 2, + alphaHex, + )}` + } + } else { + logger.warn(`Could not make '${color}' transparent`) + } + return color +} diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx new file mode 100644 index 0000000000..14c3177c54 --- /dev/null +++ b/src/components/Skeleton.tsx @@ -0,0 +1,107 @@ +import {type ReactNode} from 'react' +import {View} from 'react-native' + +import { + atoms as a, + flatten, + type TextStyleProp, + useAlf, + useTheme, + type ViewStyleProp, +} from '#/alf' +import {normalizeTextStyles} from '#/alf/typography' + +type SkeletonProps = { + blend?: boolean +} + +export function Text({blend, style}: TextStyleProp & SkeletonProps) { + const {fonts, flags, theme: t} = useAlf() + const {width, ...flattened} = flatten(style) + const {lineHeight = 14, ...rest} = normalizeTextStyles( + [a.text_sm, a.leading_snug, flattened], + { + fontScale: fonts.scaleMultiplier, + fontFamily: fonts.family, + flags, + }, + ) + return ( + + + + ) +} + +export function Circle({ + children, + size, + blend, + style, +}: ViewStyleProp & {children?: ReactNode; size: number} & SkeletonProps) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function Pill({ + size, + blend, + style, +}: ViewStyleProp & {size: number} & SkeletonProps) { + const t = useTheme() + return ( + + ) +} + +export function Col({ + children, + style, +}: ViewStyleProp & {children?: React.ReactNode}) { + return {children} +} + +export function Row({ + children, + style, +}: ViewStyleProp & {children?: React.ReactNode}) { + return {children} +} diff --git a/src/components/icons/ArrowTopCircle.tsx b/src/components/icons/ArrowTopCircle.tsx new file mode 100644 index 0000000000..2d250367f3 --- /dev/null +++ b/src/components/icons/ArrowTopCircle.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowTopCircle_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.63 3.225a1 1 0 0 1 1.337.068l3 3 .068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 10.414V16a1 1 0 1 1-2 0v-5.586l-1.293 1.293a1 1 0 1 1-1.414-1.414l3-3 .076-.068Z', +}) diff --git a/src/components/icons/CirclePlus.tsx b/src/components/icons/CirclePlus.tsx new file mode 100644 index 0000000000..690e77326e --- /dev/null +++ b/src/components/icons/CirclePlus.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CirclePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 3a1 1 0 0 1 1 1v3h3l.102.005a1 1 0 0 1 0 1.99L16 13h-3v3a1 1 0 1 1-2 0v-3H8a1 1 0 0 1 0-2h3V8a1 1 0 0 1 1-1Z', +}) diff --git a/src/components/icons/Tree.tsx b/src/components/icons/Tree.tsx new file mode 100644 index 0000000000..5c2c798720 --- /dev/null +++ b/src/components/icons/Tree.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Tree_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M6 2a2.998 2.998 0 0 1 1 5.825V8a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a2.998 2.998 0 0 1-2.826-2H9a3.98 3.98 0 0 1-2-.537V16a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a2.998 2.998 0 0 1-2.826-2H9a4 4 0 0 1-4-4V7.825A2.998 2.998 0 0 1 6 2Zm7 16a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5Zm0-8a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5ZM6 4a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z', +}) diff --git a/src/lib/async/retry.ts b/src/lib/async/retry.ts index abf78de55f..8a1729091d 100644 --- a/src/lib/async/retry.ts +++ b/src/lib/async/retry.ts @@ -1,17 +1,22 @@ +import {timeout} from '#/lib/async/timeout' import {isNetworkError} from '#/lib/strings/errors' export async function retry

( retries: number, - cond: (err: any) => boolean, - fn: () => Promise

, + shouldRetry: (err: any) => boolean, + action: () => Promise

, + delay?: number, ): Promise

{ let lastErr while (retries > 0) { try { - return await fn() + return await action() } catch (e: any) { lastErr = e - if (cond(e)) { + if (shouldRetry(e)) { + if (delay) { + await timeout(delay) + } retries-- continue } diff --git a/src/lib/hooks/useCallOnce.ts b/src/lib/hooks/useCallOnce.ts new file mode 100644 index 0000000000..fa01cf4aa3 --- /dev/null +++ b/src/lib/hooks/useCallOnce.ts @@ -0,0 +1,20 @@ +import {useCallback} from 'react' + +export enum OnceKey { + PreferencesThread = 'preferences:thread', +} + +const called: Record = { + [OnceKey.PreferencesThread]: false, +} + +export function useCallOnce(key: OnceKey) { + return useCallback( + (cb: () => void) => { + if (called[key] === true) return + called[key] = true + cb() + }, + [key], + ) +} diff --git a/src/lib/hooks/useHideBottomBarBorder.tsx b/src/lib/hooks/useHideBottomBarBorder.tsx new file mode 100644 index 0000000000..e21184fda4 --- /dev/null +++ b/src/lib/hooks/useHideBottomBarBorder.tsx @@ -0,0 +1,50 @@ +import {createContext, useCallback, useContext, useState} from 'react' +import {useFocusEffect} from '@react-navigation/native' + +type HideBottomBarBorderSetter = () => () => void + +const HideBottomBarBorderContext = createContext(false) +const HideBottomBarBorderSetterContext = + createContext(null) + +export function useHideBottomBarBorderSetter() { + const hideBottomBarBorder = useContext(HideBottomBarBorderSetterContext) + if (!hideBottomBarBorder) { + throw new Error( + 'useHideBottomBarBorderSetter must be used within a HideBottomBarBorderProvider', + ) + } + return hideBottomBarBorder +} + +export function useHideBottomBarBorderForScreen() { + const hideBorder = useHideBottomBarBorderSetter() + + useFocusEffect( + useCallback(() => { + const cleanup = hideBorder() + return () => cleanup() + }, [hideBorder]), + ) +} + +export function useHideBottomBarBorder() { + return useContext(HideBottomBarBorderContext) +} + +export function Provider({children}: {children: React.ReactNode}) { + const [refCount, setRefCount] = useState(0) + + const setter = useCallback(() => { + setRefCount(prev => prev + 1) + return () => setRefCount(prev => prev - 1) + }, []) + + return ( + + 0}> + {children} + + + ) +} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index c67bb60a3a..3b1106480d 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -6,6 +6,7 @@ export type Gate = | 'explore_show_suggested_feeds' | 'old_postonboarding' | 'onboarding_add_video_feed' + | 'post_threads_v2_unspecced' | 'remove_show_latest_button' | 'test_gate_1' | 'test_gate_2' diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index d01a92825b..31af1be2b0 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -434,4 +434,13 @@ export type MetricEvents = { 'share:press:dmSelected': {} 'share:press:recentDm': {} 'share:press:embed': {} + + 'thread:click:showOtherReplies': {} + 'thread:preferences:load': { + [key: string]: any + } + 'thread:preferences:update': { + [key: string]: any + } + 'thread:click:headerMenuOpen': {} } diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index ce33ca3aa9..c84371f2c0 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -16,6 +16,7 @@ import { RichText, } from '@atproto/api' +import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder' import {ScrollProvider} from '#/lib/ScrollContext' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import { @@ -106,6 +107,8 @@ export function MessagesList({ const getPost = useGetPost() const {embedUri, setEmbed} = useMessageEmbed() + useHideBottomBarBorderForScreen() + const flatListRef = useAnimatedRef() const [newMessagesPill, setNewMessagesPill] = useState({ diff --git a/src/screens/PostThread/components/HeaderDropdown.tsx b/src/screens/PostThread/components/HeaderDropdown.tsx new file mode 100644 index 0000000000..def3979b78 --- /dev/null +++ b/src/screens/PostThread/components/HeaderDropdown.tsx @@ -0,0 +1,106 @@ +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {HITSLOP_10} from '#/lib/constants' +import {logger} from '#/logger' +import {type ThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' +import {Button, ButtonIcon} from '#/components/Button' +import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' +import * as Menu from '#/components/Menu' + +export function HeaderDropdown({ + sort, + view, + setSort, + setView, +}: Pick< + ThreadPreferences, + 'sort' | 'setSort' | 'view' | 'setView' +>): React.ReactNode { + const {_} = useLingui() + return ( + + + {({props: {onPress, ...props}}) => ( + + )} + + + + Show replies as + + + { + setView('linear') + }}> + + Linear + + + + { + setView('tree') + }}> + + Threaded + + + + + + + Reply sorting + + + { + setSort('top') + }}> + + Top replies first + + + + { + setSort('oldest') + }}> + + Oldest replies first + + + + { + setSort('newest') + }}> + + Newest replies first + + + + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadError.tsx b/src/screens/PostThread/components/ThreadError.tsx new file mode 100644 index 0000000000..e1ca23cf97 --- /dev/null +++ b/src/screens/PostThread/components/ThreadError.tsx @@ -0,0 +1,89 @@ +import {useMemo} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useCleanError} from '#/lib/hooks/useCleanError' +import {OUTER_SPACE} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' + +export function ThreadError({ + error, + onRetry, +}: { + error: Error + onRetry: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + + const {title, message} = useMemo(() => { + let title = _(msg`Error loading post`) + let message = _(msg`Something went wrong. Please try again in a moment.`) + + const {raw, clean} = cleanError(error) + + if (error.message.startsWith('Post not found')) { + title = _(msg`Post not found`) + message = clean || raw || message + } + + return {title, message} + }, [_, error, cleanError]) + + return ( + + + + + + {title} + + + {message} + + + + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx new file mode 100644 index 0000000000..0aacd4e771 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -0,0 +1,706 @@ +import {memo, useCallback, useMemo} from 'react' +import {type GestureResponderEvent, Text as RNText, View} from 'react-native' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + type AppBskyFeedThreadgate, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useActorStatus} from '#/lib/actor-status' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {niceDate} from '#/lib/strings/time' +import {s} from '#/lib/styles' +import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' +import {logger} from '#/logger' +import { + POST_TOMBSTONE, + type Shadow, + usePostShadow, +} from '#/state/cache/post-shadow' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' +import {useLanguagePrefs} from '#/state/preferences' +import {type ThreadItem} from '#/state/queries/usePostThread/types' +import {useSession} from '#/state/session' +import {type OnPostSuccessData} from '#/state/shell/composer' +import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {type PostSource} from '#/state/unstable-post-source' +import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' +import {Link} from '#/view/com/util/Link' +import {formatCount} from '#/view/com/util/numeric/format' +import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' +import { + LINEAR_AVI_WIDTH, + OUTER_SPACE, + REPLY_LINE_WIDTH, +} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {colors} from '#/components/Admonition' +import {Button} from '#/components/Button' +import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {InlineLinkText} from '#/components/Link' +import {ContentHider} from '#/components/moderation/ContentHider' +import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {type AppModerationCause} from '#/components/Pills' +import {PostControls} from '#/components/PostControls' +import * as Prompt from '#/components/Prompt' +import {RichText} from '#/components/RichText' +import * as Skele from '#/components/Skeleton' +import {Text} from '#/components/Typography' +import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' +import {WhoCanReply} from '#/components/WhoCanReply' +import * as bsky from '#/types/bsky' + +export function ThreadItemAnchor({ + item, + onPostSuccess, + threadgateRecord, + postSource, +}: { + item: Extract + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record + postSource?: PostSource +}) { + const postShadow = usePostShadow(item.value.post) + const threadRootUri = item.value.post.record.reply?.root?.uri || item.uri + const isRoot = threadRootUri === item.uri + + if (postShadow === POST_TOMBSTONE) { + return + } + + return ( + + ) +} + +function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) { + const t = useTheme() + + return ( + <> + + + + + + + + + Post has been deleted + + + + + ) +} + +function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) { + const t = useTheme() + + return !isRoot ? ( + + + + + + ) : null +} + +const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ + item, + isRoot, + postShadow, + onPostSuccess, + threadgateRecord, + postSource, +}: { + item: Extract + isRoot: boolean + postShadow: Shadow + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record + postSource?: PostSource +}) { + const t = useTheme() + const {_, i18n} = useLingui() + const {openComposer} = useOpenComposer() + const {currentAccount, hasSession} = useSession() + const feedFeedback = useFeedFeedback(postSource?.feed, hasSession) + + const post = item.value.post + const record = item.value.post.record + const moderation = item.moderation + const authorShadow = useProfileShadow(post.author) + const {isActive: live} = useActorStatus(post.author) + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + + const threadRootUri = record.reply?.root?.uri || post.uri + const authorHref = makeProfileLink(post.author) + const authorTitle = post.author.handle + const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did + + const likesHref = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') + }, [post.uri, post.author]) + const repostsHref = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') + }, [post.uri, post.author]) + const quotesHref = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') + }, [post.uri, post.author]) + + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ + threadgateRecord, + }) + const additionalPostAlerts: AppModerationCause[] = useMemo(() => { + const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) + const isControlledByViewer = + new AtUri(threadRootUri).host === currentAccount?.did + return isControlledByViewer && isPostHiddenByThreadgate + ? [ + { + type: 'reply-hidden', + source: {type: 'user', did: currentAccount?.did}, + priority: 6, + }, + ] + : [] + }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) + const onlyFollowersCanReply = !!threadgateRecord?.allow?.find( + rule => rule.$type === 'app.bsky.feed.threadgate#followerRule', + ) + const showFollowButton = + currentAccount?.did !== post.author.did && !onlyFollowersCanReply + + const viaRepost = useMemo(() => { + const reason = postSource?.post.reason + + if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { + return { + uri: reason.uri, + cid: reason.cid, + } + } + }, [postSource]) + + const onPressReply = useCallback(() => { + openComposer({ + replyTo: { + uri: post.uri, + cid: post.cid, + text: record.text, + author: post.author, + embed: post.embed, + moderation, + }, + onPostSuccess: onPostSuccess, + }) + + if (postSource) { + feedFeedback.sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#interactionReply', + feedContext: postSource.post.feedContext, + reqId: postSource.post.reqId, + }) + } + }, [ + openComposer, + post, + record, + onPostSuccess, + moderation, + postSource, + feedFeedback, + ]) + + const onOpenAuthor = () => { + if (postSource) { + feedFeedback.sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#clickthroughAuthor', + feedContext: postSource.post.feedContext, + reqId: postSource.post.reqId, + }) + } + } + + const onOpenEmbed = () => { + if (postSource) { + feedFeedback.sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#clickthroughEmbed', + feedContext: postSource.post.feedContext, + reqId: postSource.post.reqId, + }) + } + } + + return ( + <> + + + + + + + + + + {sanitizeDisplayName( + post.author.displayName || + sanitizeHandle(post.author.handle), + moderation.ui('displayName'), + )} + + + + + + + + + + {sanitizeHandle(post.author.handle, '@')} + + + + {showFollowButton && ( + + + + )} + + + + + + {richText?.text ? ( + + ) : undefined} + {post.embed && ( + + + + )} + + + {post.repostCount !== 0 || + post.likeCount !== 0 || + post.quoteCount !== 0 ? ( + // Show this section unless we're *sure* it has no engagement. + + {post.repostCount != null && post.repostCount !== 0 ? ( + + + + {formatCount(i18n, post.repostCount)} + {' '} + + + + ) : null} + {post.quoteCount != null && + post.quoteCount !== 0 && + !post.viewer?.embeddingDisabled ? ( + + + + {formatCount(i18n, post.quoteCount)} + {' '} + + + + ) : null} + {post.likeCount != null && post.likeCount !== 0 ? ( + + + + {formatCount(i18n, post.likeCount)} + {' '} + + + + ) : null} + + ) : null} + + + + + + + + + ) +}) + +function ExpandedPostDetails({ + post, + isThreadAuthor, +}: { + post: Extract['value']['post'] + isThreadAuthor: boolean +}) { + const t = useTheme() + const {_, i18n} = useLingui() + const openLink = useOpenLink() + const langPrefs = useLanguagePrefs() + + const translatorUrl = getTranslatorLink( + post.record?.text || '', + langPrefs.primaryLanguage, + ) + const needsTranslation = useMemo( + () => + Boolean( + langPrefs.primaryLanguage && + !isPostInLanguage(post, [langPrefs.primaryLanguage]), + ), + [post, langPrefs.primaryLanguage], + ) + + const onTranslatePress = useCallback( + (e: GestureResponderEvent) => { + e.preventDefault() + openLink(translatorUrl, true) + + if ( + bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) + ) { + logger.metric('translate', { + sourceLanguages: post.record.langs ?? [], + targetLanguage: langPrefs.primaryLanguage, + textLength: post.record.text.length, + }) + } + + return false + }, + [openLink, translatorUrl, langPrefs, post], + ) + + return ( + + + + + {niceDate(i18n, post.indexedAt)} + + + {needsTranslation && ( + <> + + · + + + + Translate + + + )} + + + ) +} + +function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { + const t = useTheme() + const {_, i18n} = useLingui() + const control = Prompt.usePromptControl() + + const indexedAt = new Date(post.indexedAt) + const createdAt = bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) + ? new Date(post.record.createdAt) + : new Date(post.indexedAt) + + // backdated if createdAt is 24 hours or more before indexedAt + const isBackdated = + indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000 + + if (!isBackdated) return null + + const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light + + return ( + <> + + + + + Archived post + + + + This post claims to have been created on{' '} + {niceDate(i18n, createdAt)}, + but was first seen by Bluesky on{' '} + {niceDate(i18n, indexedAt)}. + + + + + Bluesky cannot confirm the authenticity of the claimed date. + + + + {}} /> + + + + ) +} + +function getThreadAuthor( + post: AppBskyFeedDefs.PostView, + record: AppBskyFeedPost.Record, +): string { + if (!record.reply) { + return post.author.did + } + try { + return new AtUri(record.reply.root.uri).host + } catch { + return '' + } +} + +export function ThreadItemAnchorSkeleton() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx b/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx new file mode 100644 index 0000000000..c8477e211f --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx @@ -0,0 +1,32 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import {atoms as a, useTheme} from '#/alf' +import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' +import * as Skele from '#/components/Skeleton' +import {Text} from '#/components/Typography' + +export function ThreadItemAnchorNoUnauthenticated() { + const t = useTheme() + + return ( + + + + + + + + + + + + + + + You must sign in to view this post. + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx new file mode 100644 index 0000000000..1f63b10cd9 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -0,0 +1,405 @@ +import {memo, type ReactNode, useCallback, useMemo, useState} from 'react' +import {View} from 'react-native' +import { + type AppBskyFeedDefs, + type AppBskyFeedThreadgate, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useActorStatus} from '#/lib/actor-status' +import {MAX_POST_LINES} from '#/lib/constants' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {usePalette} from '#/lib/hooks/usePalette' +import {makeProfileLink} from '#/lib/routes/links' +import {countLines} from '#/lib/strings/helpers' +import { + POST_TOMBSTONE, + type Shadow, + usePostShadow, +} from '#/state/cache/post-shadow' +import {type ThreadItem} from '#/state/queries/usePostThread/types' +import {useSession} from '#/state/session' +import {type OnPostSuccessData} from '#/state/shell/composer' +import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {TextLink} from '#/view/com/util/Link' +import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' +import {PostMeta} from '#/view/com/util/PostMeta' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' +import { + LINEAR_AVI_WIDTH, + OUTER_SPACE, + REPLY_LINE_WIDTH, +} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {PostHider} from '#/components/moderation/PostHider' +import {type AppModerationCause} from '#/components/Pills' +import {PostControls} from '#/components/PostControls' +import {RichText} from '#/components/RichText' +import * as Skele from '#/components/Skeleton' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import {Text} from '#/components/Typography' + +export type ThreadItemPostProps = { + item: Extract + overrides?: { + moderation?: boolean + topBorder?: boolean + } + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +} + +export function ThreadItemPost({ + item, + overrides, + onPostSuccess, + threadgateRecord, +}: ThreadItemPostProps) { + const postShadow = usePostShadow(item.value.post) + + if (postShadow === POST_TOMBSTONE) { + return + } + + return ( + + ) +} + +function ThreadItemPostDeleted({ + item, + overrides, +}: Pick) { + const t = useTheme() + + return ( + + + + + + + + + Post has been deleted + + + + + + ) +} + +const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({ + item, + overrides, + children, +}: Pick & { + children: ReactNode +}) { + const t = useTheme() + const showTopBorder = + !item.ui.showParentReplyLine && overrides?.topBorder !== true + + return ( + + {children} + + ) +}) + +/** + * Provides some space between posts as well as contains the reply line + */ +const ThreadItemPostParentReplyLine = memo( + function ThreadItemPostParentReplyLine({ + item, + }: Pick) { + const t = useTheme() + return ( + + + {item.ui.showParentReplyLine && ( + + )} + + + ) + }, +) + +const ThreadItemPostInner = memo(function ThreadItemPostInner({ + item, + postShadow, + overrides, + onPostSuccess, + threadgateRecord, +}: ThreadItemPostProps & { + postShadow: Shadow +}) { + const t = useTheme() + const pal = usePalette('default') + const {_} = useLingui() + const {openComposer} = useOpenComposer() + const {currentAccount} = useSession() + + const post = item.value.post + const record = item.value.post.record + const moderation = item.moderation + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + const [limitLines, setLimitLines] = useState( + () => countLines(richText?.text) >= MAX_POST_LINES, + ) + const threadRootUri = record.reply?.root?.uri || post.uri + const postHref = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ + threadgateRecord, + }) + const additionalPostAlerts: AppModerationCause[] = useMemo(() => { + const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) + const isControlledByViewer = + new AtUri(threadRootUri).host === currentAccount?.did + return isControlledByViewer && isPostHiddenByThreadgate + ? [ + { + type: 'reply-hidden', + source: {type: 'user', did: currentAccount?.did}, + priority: 6, + }, + ] + : [] + }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) + + const onPressReply = useCallback(() => { + openComposer({ + replyTo: { + uri: post.uri, + cid: post.cid, + text: record.text, + author: post.author, + embed: post.embed, + moderation, + }, + onPostSuccess: onPostSuccess, + }) + }, [openComposer, post, record, onPostSuccess, moderation]) + + const onPressShowMore = useCallback(() => { + setLimitLines(false) + }, [setLimitLines]) + + const {isActive: live} = useActorStatus(post.author) + + return ( + + + + + + + + + + {(item.ui.showChildReplyLine || + item.ui.precedesChildReadMore) && ( + + )} + + + + + + + {richText?.text ? ( + + ) : undefined} + {limitLines ? ( + + ) : undefined} + {post.embed && ( + + + + )} + + + + + + + ) +}) + +function SubtleHover({children}: {children: ReactNode}) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + return ( + + + {children} + + ) +} + +export function ThreadItemPostSkeleton({index}: {index: number}) { + const even = index % 2 === 0 + return ( + + + + + + + + + + + + {even ? ( + <> + + + + ) : ( + + )} + + + + + + + + + + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx b/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx new file mode 100644 index 0000000000..552d8f813e --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx @@ -0,0 +1,74 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import {type ThreadItem} from '#/state/queries/usePostThread/types' +import { + LINEAR_AVI_WIDTH, + OUTER_SPACE, + REPLY_LINE_WIDTH, +} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' +import * as Skele from '#/components/Skeleton' +import {Text} from '#/components/Typography' + +export function ThreadItemPostNoUnauthenticated({ + item, +}: { + item: Extract +}) { + const t = useTheme() + + return ( + + + + {item.ui.showParentReplyLine && ( + + )} + + + + + + + + + You must sign in to view this post. + + + + {item.ui.showChildReplyLine && ( + + )} + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemPostTombstone.tsx b/src/screens/PostThread/components/ThreadItemPostTombstone.tsx new file mode 100644 index 0000000000..4f1ab450bb --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemPostTombstone.tsx @@ -0,0 +1,55 @@ +import {useMemo} from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {LINEAR_AVI_WIDTH, OUTER_SPACE} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {Text} from '#/components/Typography' + +export type ThreadItemPostTombstoneProps = { + type: 'not-found' | 'blocked' +} + +export function ThreadItemPostTombstone({type}: ThreadItemPostTombstoneProps) { + const t = useTheme() + const {_} = useLingui() + const {copy, Icon} = useMemo(() => { + switch (type) { + case 'blocked': + return {copy: _(msg`Post blocked`), Icon: PersonXIcon} + case 'not-found': + default: + return {copy: _(msg`Post not found`), Icon: TrashIcon} + } + }, [_, type]) + + return ( + + + + + + + {copy} + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemReadMore.tsx b/src/screens/PostThread/components/ThreadItemReadMore.tsx new file mode 100644 index 0000000000..22ae633951 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemReadMore.tsx @@ -0,0 +1,107 @@ +import {memo} from 'react' +import {View} from 'react-native' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import { + type PostThreadParams, + type ThreadItem, +} from '#/state/queries/usePostThread' +import { + LINEAR_AVI_WIDTH, + REPLY_LINE_WIDTH, + TREE_AVI_WIDTH, + TREE_INDENT, +} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlus} from '#/components/icons/CirclePlus' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' + +export const ThreadItemReadMore = memo(function ThreadItemReadMore({ + item, + view, +}: { + item: Extract + view: PostThreadParams['view'] +}) { + const t = useTheme() + const {_} = useLingui() + const isTreeView = view === 'tree' + const indent = Math.max(0, item.depth - 1) + + const spacers = isTreeView + ? Array.from(Array(indent)).map((_, n: number) => { + const isSkipped = item.skippedIndentIndices.has(n) + return ( + + ) + }) + : null + + return ( + + {spacers} + + + {({hovered, pressed}) => { + const interacted = hovered || pressed + return ( + <> + + + + Read {item.moreReplies} more{' '} + + + + + ) + }} + + + ) +}) diff --git a/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx b/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx new file mode 100644 index 0000000000..da18a19e90 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx @@ -0,0 +1,89 @@ +import {memo} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {type ThreadItem} from '#/state/queries/usePostThread' +import { + LINEAR_AVI_WIDTH, + OUTER_SPACE, + REPLY_LINE_WIDTH, +} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {ArrowTopCircle_Stroke2_Corner0_Rounded as UpIcon} from '#/components/icons/ArrowTopCircle' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' + +export const ThreadItemReadMoreUp = memo(function ThreadItemReadMoreUp({ + item, +}: { + item: Extract +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + + {({hovered, pressed}) => { + const interacted = hovered || pressed + return ( + + + + + + + Continue thread... + + + + + + + ) + }} + + ) +}) diff --git a/src/screens/PostThread/components/ThreadItemReplyComposer.tsx b/src/screens/PostThread/components/ThreadItemReplyComposer.tsx new file mode 100644 index 0000000000..f1862569ea --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemReplyComposer.tsx @@ -0,0 +1,31 @@ +import {View} from 'react-native' + +import {OUTER_SPACE} from '#/screens/PostThread/const' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import * as Skele from '#/components/Skeleton' + +/* + * Wacky padding here is just replicating what we have in the actual + * `PostThreadComposePrompt` component + */ +export function ThreadItemReplyComposerSkeleton() { + const t = useTheme() + const {gtMobile} = useBreakpoints() + + return ( + + + + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx b/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx new file mode 100644 index 0000000000..e418375b65 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx @@ -0,0 +1,59 @@ +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {Text} from '#/components/Typography' + +export function ThreadItemShowOtherReplies({onPress}: {onPress: () => void}) { + const {_} = useLingui() + const t = useTheme() + const label = _(msg`Show more replies`) + + return ( + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx new file mode 100644 index 0000000000..d86d2ef6f7 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx @@ -0,0 +1,456 @@ +import React, {memo, useMemo} from 'react' +import {View} from 'react-native' +import { + type AppBskyFeedDefs, + type AppBskyFeedThreadgate, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {MAX_POST_LINES} from '#/lib/constants' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {usePalette} from '#/lib/hooks/usePalette' +import {makeProfileLink} from '#/lib/routes/links' +import {countLines} from '#/lib/strings/helpers' +import { + POST_TOMBSTONE, + type Shadow, + usePostShadow, +} from '#/state/cache/post-shadow' +import {type ThreadItem} from '#/state/queries/usePostThread/types' +import {useSession} from '#/state/session' +import {type OnPostSuccessData} from '#/state/shell/composer' +import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {TextLink} from '#/view/com/util/Link' +import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' +import {PostMeta} from '#/view/com/util/PostMeta' +import { + OUTER_SPACE, + REPLY_LINE_WIDTH, + TREE_AVI_WIDTH, + TREE_INDENT, +} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {PostHider} from '#/components/moderation/PostHider' +import {type AppModerationCause} from '#/components/Pills' +import {PostControls} from '#/components/PostControls' +import {RichText} from '#/components/RichText' +import * as Skele from '#/components/Skeleton' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import {Text} from '#/components/Typography' + +/** + * Mimic the space in PostMeta + */ +const TREE_AVI_PLUS_SPACE = TREE_AVI_WIDTH + a.gap_xs.gap + +export function ThreadItemTreePost({ + item, + overrides, + onPostSuccess, + threadgateRecord, +}: { + item: Extract + overrides?: { + moderation?: boolean + topBorder?: boolean + } + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}) { + const postShadow = usePostShadow(item.value.post) + + if (postShadow === POST_TOMBSTONE) { + return + } + + return ( + + ) +} + +function ThreadItemTreePostDeleted({ + item, +}: { + item: Extract +}) { + const t = useTheme() + return ( + + + + + + Post has been deleted + + + {item.ui.isLastChild && !item.ui.precedesChildReadMore && ( + + )} + + + ) +} + +const ThreadItemTreePostOuterWrapper = memo( + function ThreadItemTreePostOuterWrapper({ + item, + children, + }: { + item: Extract + children: React.ReactNode + }) { + const t = useTheme() + const indents = Math.max(0, item.ui.indent - 1) + + return ( + + {Array.from(Array(indents)).map((_, n: number) => { + const isSkipped = item.ui.skippedIndentIndices.has(n) + return ( + + ) + })} + {children} + + ) + }, +) + +const ThreadItemTreePostInnerWrapper = memo( + function ThreadItemTreePostInnerWrapper({ + item, + children, + }: { + item: Extract + children: React.ReactNode + }) { + const t = useTheme() + return ( + + {item.ui.indent > 1 && ( + + )} + {children} + + ) + }, +) + +const ThreadItemTreeReplyChildReplyLine = memo( + function ThreadItemTreeReplyChildReplyLine({ + item, + }: { + item: Extract + }) { + const t = useTheme() + return ( + + {item.ui.showChildReplyLine && ( + + )} + + ) + }, +) + +const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({ + item, + postShadow, + overrides, + onPostSuccess, + threadgateRecord, +}: { + item: Extract + postShadow: Shadow + overrides?: { + moderation?: boolean + topBorder?: boolean + } + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}): React.ReactNode { + const pal = usePalette('default') + const {_} = useLingui() + const {openComposer} = useOpenComposer() + const {currentAccount} = useSession() + + const post = item.value.post + const record = item.value.post.record + const moderation = item.moderation + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + const [limitLines, setLimitLines] = React.useState( + () => countLines(richText?.text) >= MAX_POST_LINES, + ) + const threadRootUri = record.reply?.root?.uri || post.uri + const postHref = React.useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ + threadgateRecord, + }) + const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => { + const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) + const isControlledByViewer = + new AtUri(threadRootUri).host === currentAccount?.did + return isControlledByViewer && isPostHiddenByThreadgate + ? [ + { + type: 'reply-hidden', + source: {type: 'user', did: currentAccount?.did}, + priority: 6, + }, + ] + : [] + }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) + + const onPressReply = React.useCallback(() => { + openComposer({ + replyTo: { + uri: post.uri, + cid: post.cid, + text: record.text, + author: post.author, + embed: post.embed, + moderation, + }, + onPostSuccess: onPostSuccess, + }) + }, [openComposer, post, record, onPostSuccess, moderation]) + + const onPressShowMore = React.useCallback(() => { + setLimitLines(false) + }, [setLimitLines]) + + return ( + + + + + + + + + + + + {richText?.text ? ( + + + + ) : undefined} + {limitLines ? ( + + ) : undefined} + {post.embed && ( + + + + )} + + + + + + + + + ) +}) + +function SubtleHover({children}: {children: React.ReactNode}) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + return ( + + + {children} + + ) +} + +export function ThreadItemTreePostSkeleton({index}: {index: number}) { + const t = useTheme() + const even = index % 2 === 0 + return ( + + + + + + + + + + + + {even ? ( + <> + + + + ) : ( + + )} + + + + + + + + + + + + + ) +} diff --git a/src/screens/PostThread/const.ts b/src/screens/PostThread/const.ts new file mode 100644 index 0000000000..cf559ac4e7 --- /dev/null +++ b/src/screens/PostThread/const.ts @@ -0,0 +1,7 @@ +import {tokens} from '#/alf' + +export const TREE_INDENT = tokens.space.lg +export const TREE_AVI_WIDTH = 24 +export const LINEAR_AVI_WIDTH = 42 +export const REPLY_LINE_WIDTH = 2 +export const OUTER_SPACE = tokens.space.lg diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx new file mode 100644 index 0000000000..a4f94851ad --- /dev/null +++ b/src/screens/PostThread/index.tsx @@ -0,0 +1,577 @@ +import {useCallback, useMemo, useRef, useState} from 'react' +import {useWindowDimensions, View} from 'react-native' +import Animated, {useAnimatedStyle} from 'react-native-reanimated' +import {Trans} from '@lingui/macro' + +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {useFeedFeedback} from '#/state/feed-feedback' +import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' +import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread' +import {useSession} from '#/state/session' +import {type OnPostSuccessData} from '#/state/shell/composer' +import {useShellLayout} from '#/state/shell/shell-layout' +import {useUnstablePostSource} from '#/state/unstable-post-source' +import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' +import {List, type ListMethods} from '#/view/com/util/List' +import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown' +import {ThreadError} from '#/screens/PostThread/components/ThreadError' +import { + ThreadItemAnchor, + ThreadItemAnchorSkeleton, +} from '#/screens/PostThread/components/ThreadItemAnchor' +import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated' +import { + ThreadItemPost, + ThreadItemPostSkeleton, +} from '#/screens/PostThread/components/ThreadItemPost' +import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated' +import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone' +import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore' +import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp' +import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer' +import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies' +import { + ThreadItemTreePost, + ThreadItemTreePostSkeleton, +} from '#/screens/PostThread/components/ThreadItemTreePost' +import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' +import * as Layout from '#/components/Layout' +import {ListFooter} from '#/components/Lists' + +const PARENT_CHUNK_SIZE = 5 +const CHILDREN_CHUNK_SIZE = 50 + +export function PostThread({uri}: {uri: string}) { + const {gtMobile} = useBreakpoints() + const {hasSession} = useSession() + const initialNumToRender = useInitialNumToRender() // TODO + const {height: windowHeight} = useWindowDimensions() + const anchorPostSource = useUnstablePostSource(uri) + const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) + + /* + * One query to rule them all + */ + const thread = usePostThread({anchor: uri}) + const anchor = useMemo(() => { + for (const item of thread.data.items) { + if (item.type === 'threadPost' && item.depth === 0) { + return item + } + } + return + }, [thread.data.items]) + + const {openComposer} = useOpenComposer() + const optimisticOnPostReply = useCallback( + (payload: OnPostSuccessData) => { + if (payload) { + const {replyToUri, posts} = payload + if (replyToUri && posts.length) { + thread.actions.insertReplies(replyToUri, posts) + } + } + }, + [thread], + ) + const onReplyToAnchor = useCallback(() => { + if (anchor?.type !== 'threadPost') { + return + } + const post = anchor.value.post + openComposer({ + replyTo: { + uri: anchor.uri, + cid: post.cid, + text: post.record.text, + author: post.author, + embed: post.embed, + moderation: anchor.moderation, + }, + onPostSuccess: optimisticOnPostReply, + }) + + if (anchorPostSource) { + feedFeedback.sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#interactionReply', + feedContext: anchorPostSource.post.feedContext, + reqId: anchorPostSource.post.reqId, + }) + } + }, [ + anchor, + openComposer, + optimisticOnPostReply, + anchorPostSource, + feedFeedback, + ]) + + const isRoot = !!anchor && anchor.value.post.record.reply === undefined + const canReply = !anchor?.value.post?.viewer?.replyDisabled + const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE) + const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE) + const totalParentCount = useRef(0) // recomputed below + const totalChildrenCount = useRef(thread.data.items.length) // recomputed below + const listRef = useRef(null) + const anchorRef = useRef(null) + const headerRef = useRef(null) + + /* + * On a cold load, parents are not prepended until the anchor post has + * rendered as the first item in the list. This gives us a consistent + * reference point for which to pin the anchor post to the top of the screen. + * + * We simulate a cold load any time the user changes the view or sort params + * so that this handling is consistent. + * + * On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives + * us this for free, since the anchor post is the first item in the list. + * + * On web, `onContentSizeChange` is used to get ahead of next paint and handle + * this scrolling. + */ + const [deferParents, setDeferParents] = useState(true) + /** + * Used to flag whether we should scroll to the anchor post. On a cold load, + * this is always true. And when a user changes thread parameters, we also + * manually set this to true. + */ + const shouldHandleScroll = useRef(true) + /** + * Called any time the content size of the list changes, _just_ before paint. + * + * We want this to fire every time we change params (which will reset + * `deferParents` via `onLayout` on the anchor post, due to the key change), + * or click into a new post (which will result in a fresh `deferParents` + * hook). + * + * The result being: any intentional change in view by the user will result + * in the anchor being pinned as the first item. + */ + const onContentSizeChangeWebOnly = web(() => { + const list = listRef.current + const anchor = anchorRef.current as any as Element + const header = headerRef.current as any as Element + + if (list && anchor && header && shouldHandleScroll.current) { + const anchorOffsetTop = anchor.getBoundingClientRect().top + const headerHeight = header.getBoundingClientRect().height + + /* + * `deferParents` is `true` on a cold load, and always reset to + * `true` when params change via `prepareForParamsUpdate`. + * + * On a cold load or a push to a new post, on the first pass of this + * logic, the anchor post is the first item in the list. Therefore + * `anchorOffsetTop - headerHeight` will be 0. + * + * When a user changes thread params, on the first pass of this logic, + * the anchor post may not move (if there are no parents above it), or it + * may have gone off the screen above, because of the sudden lack of + * parents due to `deferParents === true`. This negative value (minus + * `headerHeight`) will result in a _negative_ `offset` value, which will + * scroll the anchor post _down_ to the top of the screen. + * + * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user + * changes params, the anchor post's offset will actually be equivalent + * to the `headerHeight` because of how the DOM is stacked on web. + * Therefore, `anchorOffsetTop - headerHeight` will once again be 0, + * which means the first pass in this case will result in no scroll. + * + * Then, once parents are prepended, this will fire again. Now, the + * `anchorOffsetTop` will be positive, which minus the header height, + * will give us a _positive_ offset, which will scroll the anchor post + * back _up_ to the top of the screen. + */ + list.scrollToOffset({ + offset: anchorOffsetTop - headerHeight, + }) + + /* + * After the second pass, `deferParents` will be `false`, and we need + * to ensure this doesn't run again until scroll handling is requested + * again via `shouldHandleScroll.current === true` and a params + * change via `prepareForParamsUpdate`. + * + * The `isRoot` here is needed because if we're looking at the anchor + * post, this handler will not fire after `deferParents` is set to + * `false`, since there are no parents to render above it. In this case, + * we want to make sure `shouldHandleScroll` is set to `false` so that + * subsequent size changes unrelated to a params change (like pagination) + * do not affect scroll. + */ + if (!deferParents || isRoot) shouldHandleScroll.current = false + } + }) + + /** + * Ditto the above, but for native. + */ + const onContentSizeChangeNativeOnly = native(() => { + const list = listRef.current + const anchor = anchorRef.current + + if (list && anchor && shouldHandleScroll.current) { + /* + * `prepareForParamsUpdate` is called any time the user changes thread params like + * `view` or `sort`, which sets `deferParents(true)` and resets the + * scroll to the top of the list. However, there is a split second + * where the top of the list is wherever the parents _just were_. So if + * there were parents, the anchor is not at the top of the list just + * prior to this handler being called. + * + * Once this handler is called, the anchor post is the first item in + * the list (because of `deferParents` being `true`), and so we can + * synchronously scroll the list back to the top of the list (which is + * 0 on native, no need to handle `headerHeight`). + */ + list.scrollToOffset({ + animated: false, + offset: 0, + }) + + /* + * After this first pass, `deferParents` will be `false`, and those + * will render in. However, the anchor post will retain its position + * because of `maintainVisibleContentPosition` handling on native. So we + * don't need to let this handler run again, like we do on web. + */ + shouldHandleScroll.current = false + } + }) + + /** + * Called any time the user changes thread params, such as `view` or `sort`. + * Prepares the UI for repositioning of the scroll so that the anchor post is + * always at the top after a params change. + * + * No need to handle max parents here, deferParents will handle that and we + * want it to re-render with the same items above the anchor. + */ + const prepareForParamsUpdate = useCallback(() => { + /** + * Truncate list so that anchor post is the first item in the list. Manual + * scroll handling on web is predicated on this, and on native, this allows + * `maintainVisibleContentPosition` to do its thing. + */ + setDeferParents(true) + // reset this to a lower value for faster re-render + setMaxChildrenCount(CHILDREN_CHUNK_SIZE) + // set flag + shouldHandleScroll.current = true + }, [setDeferParents, setMaxChildrenCount]) + + const setSortWrapped = useCallback( + (sort: string) => { + prepareForParamsUpdate() + thread.actions.setSort(sort) + }, + [thread, prepareForParamsUpdate], + ) + + const setViewWrapped = useCallback( + (view: ThreadViewOption) => { + prepareForParamsUpdate() + thread.actions.setView(view) + }, + [thread, prepareForParamsUpdate], + ) + + const onStartReached = () => { + if (thread.state.isFetching) return + // can be true after `prepareForParamsUpdate` is called + if (deferParents) return + // prevent any state mutations if we know we're done + if (maxParentCount >= totalParentCount.current) return + setMaxParentCount(n => n + PARENT_CHUNK_SIZE) + } + + const onEndReached = () => { + if (thread.state.isFetching) return + // can be true after `prepareForParamsUpdate` is called + if (deferParents) return + // prevent any state mutations if we know we're done + if (maxChildrenCount >= totalChildrenCount.current) return + setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE) + } + + const slices = useMemo(() => { + const results: ThreadItem[] = [] + + if (!thread.data.items.length) return results + + /* + * Pagination hack, tracks the # of items below the anchor post. + */ + let childrenCount = 0 + + for (let i = 0; i < thread.data.items.length; i++) { + const item = thread.data.items[i] + /* + * Need to check `depth`, since not found or blocked posts are not + * `threadPost`s, but still have `depth`. + */ + const hasDepth = 'depth' in item + + /* + * Handle anchor post. + */ + if (hasDepth && item.depth === 0) { + results.push(item) + + // Recalculate total parents current index. + totalParentCount.current = i + // Recalculate total children using (length - 1) - current index. + totalChildrenCount.current = thread.data.items.length - 1 - i + + /* + * Walk up the parents, limiting by `maxParentCount` + */ + if (!deferParents) { + const start = i - 1 + if (start >= 0) { + const limit = Math.max(0, start - maxParentCount) + for (let pi = start; pi >= limit; pi--) { + results.unshift(thread.data.items[pi]) + } + } + } + } else { + // ignore any parent items + if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue + // can exit early if we've reached the max children count + if (childrenCount > maxChildrenCount) break + + results.push(item) + childrenCount++ + } + } + + return results + }, [thread, deferParents, maxParentCount, maxChildrenCount]) + + const isTombstoneView = useMemo(() => { + if (slices.length > 1) return false + return slices.every( + s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound', + ) + }, [slices]) + + const renderItem = useCallback( + ({item, index}: {item: ThreadItem; index: number}) => { + if (item.type === 'threadPost') { + if (item.depth < 0) { + return ( + + ) + } else if (item.depth === 0) { + return ( + /* + * Keep this view wrapped so that the anchor post is always index 0 + * in the list and `maintainVisibleContentPosition` can do its + * thing. + */ + + setDeferParents(false)} + /> + + + ) + } else { + if (thread.state.view === 'tree') { + return ( + 0, + }} + onPostSuccess={optimisticOnPostReply} + /> + ) + } else { + return ( + 0, + }} + onPostSuccess={optimisticOnPostReply} + /> + ) + } + } + } else if (item.type === 'threadPostNoUnauthenticated') { + if (item.depth < 0) { + return + } else if (item.depth === 0) { + return + } + } else if (item.type === 'readMore') { + return ( + + ) + } else if (item.type === 'readMoreUp') { + return + } else if (item.type === 'threadPostBlocked') { + return + } else if (item.type === 'threadPostNotFound') { + return + } else if (item.type === 'replyComposer') { + return ( + + {gtMobile && ( + + )} + + ) + } else if (item.type === 'showOtherReplies') { + return + } else if (item.type === 'skeleton') { + if (item.item === 'anchor') { + return + } else if (item.item === 'reply') { + if (thread.state.view === 'linear') { + return + } else { + return + } + } else if (item.item === 'replyComposer') { + return + } + } + return null + }, + [ + thread, + optimisticOnPostReply, + onReplyToAnchor, + gtMobile, + anchorPostSource, + ], + ) + + return ( + <> + + + + + Post + + + + + + + + {thread.state.error ? ( + + ) : ( + + } + initialNumToRender={initialNumToRender} + windowSize={11} + sideBorders={false} + /> + )} + + {!gtMobile && canReply && hasSession && ( + + )} + + ) +} + +function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { + const {footerHeight} = useShellLayout() + + const animatedStyle = useAnimatedStyle(() => { + return { + bottom: footerHeight.get(), + } + }) + + return ( + + + + ) +} + +const keyExtractor = (item: ThreadItem) => { + return item.key +} diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx index 701d3d9e56..af3cf915f5 100644 --- a/src/screens/Settings/ThreadPreferences.tsx +++ b/src/screens/Settings/ThreadPreferences.tsx @@ -2,22 +2,156 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useGate} from '#/lib/statsig/statsig' import { usePreferencesQuery, useSetThreadViewPreferencesMutation, } from '#/state/queries/preferences' +import { + normalizeSort, + normalizeView, + useThreadPreferences, +} from '#/state/queries/preferences/useThreadPreferences' import {atoms as a, useTheme} from '#/alf' import * as Toggle from '#/components/forms/Toggle' import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' +import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree' import * as Layout from '#/components/Layout' import {Text} from '#/components/Typography' import * as SettingsList from './components/SettingsList' type Props = NativeStackScreenProps export function ThreadPreferencesScreen({}: Props) { + const gate = useGate() + + return gate('post_threads_v2_unspecced') ? ( + + ) : ( + + ) +} + +export function ThreadPreferencesV2() { + const t = useTheme() + const {_} = useLingui() + const { + sort, + setSort, + view, + setView, + prioritizeFollowedUsers, + setPrioritizeFollowedUsers, + } = useThreadPreferences({save: true}) + + return ( + + + + + + Thread Preferences + + + + + + + + + + Sort replies + + + + Sort replies to the same post by: + + setSort(normalizeSort(values[0]))}> + + + + + Top replies first + + + + + + Oldest replies first + + + + + + Newest replies first + + + + + + + + + + + Prioritize your Follows + + setPrioritizeFollowedUsers(value)} + style={[a.w_full, a.gap_md]}> + + + Show replies by people you follow before all other replies + + + + + + + + + + Tree view + + + setView(normalizeView({treeViewEnabled: value})) + } + style={[a.w_full, a.gap_md]}> + + Show post replies in a threaded tree view + + + + + + + + ) +} + +export function ThreadPreferencesV1() { const {_} = useLingui() const t = useTheme() diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index 8a75751f72..495b3bc622 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -882,7 +882,10 @@ function Overlay({ player={player} seekingAnimationSV={seekingAnimationSV} scrollGesture={scrollGesture}> - + diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts index 3f9644879b..90fddda2bd 100644 --- a/src/state/cache/post-shadow.ts +++ b/src/state/cache/post-shadow.ts @@ -14,6 +14,7 @@ import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/qu import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread' import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' +import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache' import {useProfileShadow} from './profile-shadow' import {castAsShadow, type Shadow} from './types' export type {Shadow} from './types' @@ -157,6 +158,9 @@ function* findPostsInCache( yield node.post } } + for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) { + yield post + } for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { yield post } diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index a1212d8a29..31bf55d132 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -21,6 +21,7 @@ import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows' import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows' import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery' +import {findAllProfilesInQueryData as findAllProfilesInPostThreadV2QueryData} from '#/state/queries/usePostThread/queryCache' import type * as bsky from '#/types/bsky' import {castAsShadow, type Shadow} from './types' @@ -167,6 +168,7 @@ function* findProfilesInCache( yield* findAllProfilesInListConvosQueryData(queryClient, did) yield* findAllProfilesInFeedsQueryData(queryClient, did) yield* findAllProfilesInPostThreadQueryData(queryClient, did) + yield* findAllProfilesInPostThreadV2QueryData(queryClient, did) yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) } diff --git a/src/state/queries/preferences/useThreadPreferences.ts b/src/state/queries/preferences/useThreadPreferences.ts new file mode 100644 index 0000000000..dc3122a72a --- /dev/null +++ b/src/state/queries/preferences/useThreadPreferences.ts @@ -0,0 +1,179 @@ +import {useCallback, useMemo, useRef, useState} from 'react' +import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api' +import debounce from 'lodash.debounce' + +import {OnceKey, useCallOnce} from '#/lib/hooks/useCallOnce' +import {logger} from '#/logger' +import { + usePreferencesQuery, + useSetThreadViewPreferencesMutation, +} from '#/state/queries/preferences' +import {type ThreadViewPreferences} from '#/state/queries/preferences/types' +import {type Literal} from '#/types/utils' + +export type ThreadSortOption = Literal< + AppBskyUnspeccedGetPostThreadV2.QueryParams['sort'], + string +> +export type ThreadViewOption = 'linear' | 'tree' +export type ThreadPreferences = { + isLoaded: boolean + isSaving: boolean + sort: ThreadSortOption + setSort: (sort: string) => void + view: ThreadViewOption + setView: (view: ThreadViewOption) => void + prioritizeFollowedUsers: boolean + setPrioritizeFollowedUsers: (prioritize: boolean) => void +} + +export function useThreadPreferences({ + save, +}: {save?: boolean} = {}): ThreadPreferences { + const {data: preferences} = usePreferencesQuery() + const serverPrefs = preferences?.threadViewPrefs + const once = useCallOnce(OnceKey.PreferencesThread) + + /* + * Create local state representations of server state + */ + const [sort, setSort] = useState(normalizeSort(serverPrefs?.sort || 'top')) + const [view, setView] = useState( + normalizeView({ + treeViewEnabled: !!serverPrefs?.lab_treeViewEnabled, + }), + ) + const [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState( + !!serverPrefs?.prioritizeFollowedUsers, + ) + + /** + * If we get a server update, update local state + */ + const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs) + const isLoaded = !!prevServerPrefs + if (serverPrefs && prevServerPrefs !== serverPrefs) { + setPrevServerPrefs(serverPrefs) + + /* + * Update + */ + setSort(normalizeSort(serverPrefs.sort)) + setPrioritizeFollowedUsers(serverPrefs.prioritizeFollowedUsers) + setView( + normalizeView({ + treeViewEnabled: !!serverPrefs.lab_treeViewEnabled, + }), + ) + + once(() => { + logger.metric('thread:preferences:load', { + sort: serverPrefs.sort, + view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear', + prioritizeFollowedUsers: serverPrefs.prioritizeFollowedUsers, + }) + }) + } + + const userUpdatedPrefs = useRef(false) + const [isSaving, setIsSaving] = useState(false) + const {mutateAsync} = useSetThreadViewPreferencesMutation() + const savePrefs = useMemo(() => { + return debounce(async (prefs: ThreadViewPreferences) => { + try { + setIsSaving(true) + await mutateAsync(prefs) + logger.metric('thread:preferences:update', { + sort: prefs.sort, + view: prefs.lab_treeViewEnabled ? 'tree' : 'linear', + prioritizeFollowedUsers: prefs.prioritizeFollowedUsers, + }) + } catch (e) { + logger.error('useThreadPreferences failed to save', { + safeMessage: e, + }) + } finally { + setIsSaving(false) + } + }, 4e3) + }, [mutateAsync]) + + if (save && userUpdatedPrefs.current) { + savePrefs({ + sort, + prioritizeFollowedUsers, + lab_treeViewEnabled: view === 'tree', + }) + userUpdatedPrefs.current = false + } + + const setSortWrapped = useCallback( + (next: string) => { + userUpdatedPrefs.current = true + setSort(normalizeSort(next)) + }, + [setSort], + ) + const setViewWrapped = useCallback( + (next: ThreadViewOption) => { + userUpdatedPrefs.current = true + setView(next) + }, + [setView], + ) + const setPrioritizeFollowedUsersWrapped = useCallback( + (next: boolean) => { + userUpdatedPrefs.current = true + setPrioritizeFollowedUsers(next) + }, + [setPrioritizeFollowedUsers], + ) + + return useMemo( + () => ({ + isLoaded, + isSaving, + sort, + setSort: setSortWrapped, + view, + setView: setViewWrapped, + prioritizeFollowedUsers, + setPrioritizeFollowedUsers: setPrioritizeFollowedUsersWrapped, + }), + [ + isLoaded, + isSaving, + sort, + setSortWrapped, + view, + setViewWrapped, + prioritizeFollowedUsers, + setPrioritizeFollowedUsersWrapped, + ], + ) +} + +/** + * Migrates user thread preferences from the old sort values to V2 + */ +export function normalizeSort(sort: string): ThreadSortOption { + switch (sort) { + case 'oldest': + return 'oldest' + case 'newest': + return 'newest' + default: + return 'top' + } +} + +/** + * Transforms existing treeViewEnabled preference into a ThreadViewOption + */ +export function normalizeView({ + treeViewEnabled, +}: { + treeViewEnabled: boolean +}): ThreadViewOption { + return treeViewEnabled ? 'tree' : 'linear' +} diff --git a/src/state/queries/usePostThread/const.ts b/src/state/queries/usePostThread/const.ts new file mode 100644 index 0000000000..9b74361307 --- /dev/null +++ b/src/state/queries/usePostThread/const.ts @@ -0,0 +1,27 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api' + +/** + * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} + */ +export const LINEAR_VIEW_BELOW = 10 + +/** + * See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} + */ +export const LINEAR_VIEW_BF = 1 + +/** + * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} + */ +export const TREE_VIEW_BELOW = 4 + +/** + * See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} + */ +export const TREE_VIEW_BF = undefined + +/** + * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} + */ +export const TREE_VIEW_BELOW_DESKTOP = 6 diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts new file mode 100644 index 0000000000..782888cfbe --- /dev/null +++ b/src/state/queries/usePostThread/index.ts @@ -0,0 +1,325 @@ +import {useCallback, useMemo, useState} from 'react' +import {useQuery, useQueryClient} from '@tanstack/react-query' + +import {isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' +import { + LINEAR_VIEW_BELOW, + LINEAR_VIEW_BF, + TREE_VIEW_BELOW, + TREE_VIEW_BELOW_DESKTOP, + TREE_VIEW_BF, +} from '#/state/queries/usePostThread/const' +import { + createCacheMutator, + getThreadPlaceholder, +} from '#/state/queries/usePostThread/queryCache' +import { + buildThread, + sortAndAnnotateThreadItems, +} from '#/state/queries/usePostThread/traversal' +import { + createPostThreadOtherQueryKey, + createPostThreadQueryKey, + type ThreadItem, + type UsePostThreadQueryResult, +} from '#/state/queries/usePostThread/types' +import {getThreadgateRecord} from '#/state/queries/usePostThread/utils' +import * as views from '#/state/queries/usePostThread/views' +import {useAgent, useSession} from '#/state/session' +import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {useBreakpoints} from '#/alf' + +export * from '#/state/queries/usePostThread/types' + +export function usePostThread({anchor}: {anchor?: string}) { + const qc = useQueryClient() + const agent = useAgent() + const {hasSession} = useSession() + const {gtPhone} = useBreakpoints() + const moderationOpts = useModerationOpts() + const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies() + const { + isLoaded: isThreadPreferencesLoaded, + sort, + setSort: baseSetSort, + view, + setView: baseSetView, + prioritizeFollowedUsers, + } = useThreadPreferences() + const below = useMemo(() => { + return view === 'linear' + ? LINEAR_VIEW_BELOW + : isWeb && gtPhone + ? TREE_VIEW_BELOW_DESKTOP + : TREE_VIEW_BELOW + }, [view, gtPhone]) + + const postThreadQueryKey = createPostThreadQueryKey({ + anchor, + sort, + view, + prioritizeFollowedUsers, + }) + const postThreadOtherQueryKey = createPostThreadOtherQueryKey({ + anchor, + prioritizeFollowedUsers, + }) + + const query = useQuery({ + enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts, + queryKey: postThreadQueryKey, + async queryFn(ctx) { + const {data} = await agent.app.bsky.unspecced.getPostThreadV2({ + anchor: anchor!, + branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF, + below, + sort: sort, + prioritizeFollowedUsers: prioritizeFollowedUsers, + }) + + /* + * Initialize `ctx.meta` to track if we know we have additional replies + * we could fetch once we hit the end. + */ + ctx.meta = ctx.meta || { + hasOtherReplies: false, + } + + /* + * If we know we have additional replies, we'll set this to true. + */ + if (data.hasOtherReplies) { + ctx.meta.hasOtherReplies = true + } + + const result = { + thread: data.thread || [], + threadgate: data.threadgate, + hasOtherReplies: !!ctx.meta.hasOtherReplies, + } + + const record = getThreadgateRecord(result.threadgate) + if (result.threadgate && record) { + result.threadgate.record = record + } + + return result as UsePostThreadQueryResult + }, + placeholderData() { + if (!anchor) return + const placeholder = getThreadPlaceholder(qc, anchor) + /* + * Always return something here, even empty data, so that + * `isPlaceholderData` is always true, which we'll use to insert + * skeletons. + */ + const thread = placeholder ? [placeholder] : [] + return {thread, threadgate: undefined, hasOtherReplies: false} + }, + select(data) { + const record = getThreadgateRecord(data.threadgate) + if (data.threadgate && record) { + data.threadgate.record = record + } + return data + }, + }) + + const thread = useMemo(() => query.data?.thread || [], [query.data?.thread]) + const threadgate = useMemo( + () => query.data?.threadgate, + [query.data?.threadgate], + ) + const hasOtherThreadItems = useMemo( + () => !!query.data?.hasOtherReplies, + [query.data?.hasOtherReplies], + ) + const [otherItemsVisible, setOtherItemsVisible] = useState(false) + + /** + * Creates a mutator for the post thread cache. This is used to insert + * replies into the thread cache after posting. + */ + const mutator = useMemo( + () => + createCacheMutator({ + params: {view, below}, + postThreadQueryKey, + postThreadOtherQueryKey, + queryClient: qc, + }), + [qc, view, below, postThreadQueryKey, postThreadOtherQueryKey], + ) + + /** + * If we have additional items available from the server and the user has + * chosen to view them, start loading data + */ + const additionalQueryEnabled = hasOtherThreadItems && otherItemsVisible + const additionalItemsQuery = useQuery({ + enabled: additionalQueryEnabled, + queryKey: postThreadOtherQueryKey, + async queryFn() { + const {data} = await agent.app.bsky.unspecced.getPostThreadOtherV2({ + anchor: anchor!, + prioritizeFollowedUsers, + }) + return data + }, + }) + const serverOtherThreadItems: ThreadItem[] = useMemo(() => { + if (!additionalQueryEnabled) return [] + if (additionalItemsQuery.isLoading) { + return Array.from({length: 2}).map((_, i) => + views.skeleton({ + key: `other-reply-${i}`, + item: 'reply', + }), + ) + } else if (additionalItemsQuery.isError) { + /* + * We could insert an special error component in here, but since these + * are optional additional replies, it's not critical that they're shown + * atm. + */ + return [] + } else if (additionalItemsQuery.data?.thread) { + const {threadItems} = sortAndAnnotateThreadItems( + additionalItemsQuery.data.thread, + { + view, + skipModerationHandling: true, + threadgateHiddenReplies: mergeThreadgateHiddenReplies( + threadgate?.record, + ), + moderationOpts: moderationOpts!, + }, + ) + return threadItems + } else { + return [] + } + }, [ + view, + additionalQueryEnabled, + additionalItemsQuery, + mergeThreadgateHiddenReplies, + moderationOpts, + threadgate?.record, + ]) + + /** + * Sets the sort order for the thread and resets the additional thread items + */ + const setSort: typeof baseSetSort = useCallback( + nextSort => { + setOtherItemsVisible(false) + baseSetSort(nextSort) + }, + [baseSetSort, setOtherItemsVisible], + ) + + /** + * Sets the view variant for the thread and resets the additional thread items + */ + const setView: typeof baseSetView = useCallback( + nextView => { + setOtherItemsVisible(false) + baseSetView(nextView) + }, + [baseSetView, setOtherItemsVisible], + ) + + /* + * This is the main thread response, sorted into separate buckets based on + * moderation, and annotated with all UI state needed for rendering. + */ + const {threadItems, otherThreadItems} = useMemo(() => { + return sortAndAnnotateThreadItems(thread, { + view: view, + threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record), + moderationOpts: moderationOpts!, + }) + }, [ + thread, + threadgate?.record, + mergeThreadgateHiddenReplies, + moderationOpts, + view, + ]) + + /* + * Take all three sets of thread items and combine them into a single thread, + * along with any other thread items required for rendering e.g. "Show more + * replies" or the reply composer. + */ + const items = useMemo(() => { + return buildThread({ + threadItems, + otherThreadItems, + serverOtherThreadItems, + isLoading: query.isPlaceholderData, + hasSession, + hasOtherThreadItems, + otherItemsVisible, + showOtherItems: () => setOtherItemsVisible(true), + }) + }, [ + threadItems, + otherThreadItems, + serverOtherThreadItems, + query.isPlaceholderData, + hasSession, + hasOtherThreadItems, + otherItemsVisible, + setOtherItemsVisible, + ]) + + return useMemo( + () => ({ + state: { + /* + * Copy in any query state that is useful + */ + isFetching: query.isFetching, + isPlaceholderData: query.isPlaceholderData, + error: query.error, + /* + * Other state + */ + sort, + view, + otherItemsVisible, + }, + data: { + items, + threadgate, + }, + actions: { + /* + * Copy in any query actions that are useful + */ + insertReplies: mutator.insertReplies, + refetch: query.refetch, + /* + * Other actions + */ + setSort, + setView, + }, + }), + [ + query, + mutator.insertReplies, + otherItemsVisible, + sort, + view, + setSort, + setView, + threadgate, + items, + ], + ) +} diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts new file mode 100644 index 0000000000..871033395f --- /dev/null +++ b/src/state/queries/usePostThread/queryCache.ts @@ -0,0 +1,300 @@ +import { + type $Typed, + type AppBskyActorDefs, + type AppBskyFeedDefs, + AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadOtherV2, + 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 {getBranch} from '#/state/queries/usePostThread/traversal' +import { + type ApiThreadItem, + type createPostThreadOtherQueryKey, + type createPostThreadQueryKey, + type PostThreadParams, + postThreadQueryKeyRoot, +} from '#/state/queries/usePostThread/types' +import {getRootPostAtUri} from '#/state/queries/usePostThread/utils' +import {postViewToThreadPlaceholder} from '#/state/queries/usePostThread/views' +import {didOrHandleUriMatches, getEmbeddedPost} from '#/state/queries/util' +import {embedViewRecordToPostView} from '#/state/queries/util' + +export function createCacheMutator({ + queryClient, + postThreadQueryKey, + postThreadOtherQueryKey, + params, +}: { + queryClient: QueryClient + postThreadQueryKey: ReturnType + postThreadOtherQueryKey: ReturnType + params: Pick & {below: number} +}) { + return { + insertReplies( + parentUri: string, + replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[], + ) { + /* + * Main thread query mutator. + */ + queryClient.setQueryData( + postThreadQueryKey, + data => { + if (!data) return + return { + ...data, + thread: mutator([ + ...data.thread, + ]), + } + }, + ) + + /* + * Additional replies query mutator. + */ + queryClient.setQueryData( + postThreadOtherQueryKey, + data => { + if (!data) return + return { + ...data, + thread: mutator([ + ...data.thread, + ]), + } + }, + ) + + function mutator(thread: ApiThreadItem[]): T[] { + for (let i = 0; i < thread.length; i++) { + const existingParent = thread[i] + if (!AppBskyUnspeccedDefs.isThreadItemPost(existingParent.value)) + continue + if (existingParent.uri !== parentUri) continue + + /* + * Update parent data + */ + existingParent.value.post = { + ...existingParent.value.post, + replyCount: (existingParent.value.post.replyCount || 0) + 1, + } + + const opDid = getRootPostAtUri(existingParent.value.post)?.host + const nextItem = thread.at(i + 1) + const isReplyToRoot = existingParent.depth === 0 + const isEndOfReplyChain = + !nextItem || nextItem.depth <= existingParent.depth + const firstReply = replies.at(0) + const opIsReplier = AppBskyUnspeccedDefs.isThreadItemPost( + firstReply?.value, + ) + ? opDid === firstReply.value.post.author.did + : false + + /* + * Always insert replies if the following conditions are met. + */ + const shouldAlwaysInsertReplies = + isReplyToRoot || + params.view === 'tree' || + (params.view === 'linear' && isEndOfReplyChain) + /* + * Maybe insert replies if the replier is the OP and certain conditions are met + */ + const shouldReplaceWithOPReplies = + !isReplyToRoot && params.view === 'linear' && opIsReplier + + if (shouldAlwaysInsertReplies || shouldReplaceWithOPReplies) { + const branch = getBranch(thread, i, existingParent.depth) + /* + * OP insertions replace other replies _in linear view_. + */ + const itemsToRemove = shouldReplaceWithOPReplies ? branch.length : 0 + const itemsToInsert = replies + .map((r, ri) => { + r.depth = existingParent.depth + 1 + ri + return r + }) + .filter(r => { + // Filter out replies that are too deep for our UI + return r.depth <= params.below + }) + + thread.splice(i + 1, itemsToRemove, ...itemsToInsert) + } + } + + return thread as T[] + } + }, + /** + * Unused atm, post shadow does the trick, but it would be nice to clean up + * the whole sub-tree on deletes. + */ + deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) { + queryClient.setQueryData( + postThreadQueryKey, + queryData => { + if (!queryData) return + + const thread = [...queryData.thread] + + for (let i = 0; i < thread.length; i++) { + const existingPost = thread[i] + if (!AppBskyUnspeccedDefs.isThreadItemPost(post.value)) continue + + if (existingPost.uri === post.uri) { + const branch = getBranch(thread, i, existingPost.depth) + thread.splice(branch.start, branch.length) + break + } + } + + return { + ...queryData, + thread, + } + }, + ) + }, + } +} + +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.value.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< + Omit & { + value: $Typed + } + >, + void +> { + /* + * Check post thread queries first + */ + for (const post of findAllPostsInQueryData(queryClient, uri)) { + yield postViewToThreadPlaceholder(post) + } + + /* + * 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) + } +} + +export function* findAllPostsInQueryData( + queryClient: QueryClient, + uri: string, +): Generator { + const atUri = new AtUri(uri) + 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.value)) { + if (didOrHandleUriMatches(atUri, item.value.post)) { + yield item.value.post + } + + const qp = getEmbeddedPost(item.value.post.embed) + if (qp && didOrHandleUriMatches(atUri, qp)) { + yield embedViewRecordToPostView(qp) + } + } + } + } +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator { + 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.value)) { + if (item.value.post.author.did === did) { + yield item.value.post.author + } + + const qp = getEmbeddedPost(item.value.post.embed) + if (qp && qp.author.did === did) { + yield qp.author + } + } + } + } +} diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts new file mode 100644 index 0000000000..fbae4ecdbf --- /dev/null +++ b/src/state/queries/usePostThread/traversal.ts @@ -0,0 +1,539 @@ +/* eslint-disable no-labels */ +import {AppBskyUnspeccedDefs, type ModerationOpts} from '@atproto/api' + +import { + type ApiThreadItem, + type PostThreadParams, + type ThreadItem, + type TraversalMetadata, +} from '#/state/queries/usePostThread/types' +import { + getPostRecord, + getThreadPostNoUnauthenticatedUI, + getThreadPostUI, + getTraversalMetadata, + storeTraversalMetadata, +} from '#/state/queries/usePostThread/utils' +import * as views from '#/state/queries/usePostThread/views' + +export function sortAndAnnotateThreadItems( + thread: ApiThreadItem[], + { + threadgateHiddenReplies, + moderationOpts, + view, + skipModerationHandling, + }: { + threadgateHiddenReplies: Set + moderationOpts: ModerationOpts + view: PostThreadParams['view'] + /** + * Set to `true` in cases where we already know the moderation state of the + * post e.g. when fetching additional replies from the server. This will + * prevent additional sorting or nested-branch truncation, and all replies, + * regardless of moderation state, will be included in the resulting + * `threadItems` array. + */ + skipModerationHandling?: boolean + }, +) { + const threadItems: ThreadItem[] = [] + const otherThreadItems: ThreadItem[] = [] + const metadatas = new Map() + + traversal: for (let i = 0; i < thread.length; i++) { + const item = thread[i] + let parentMetadata: TraversalMetadata | undefined + let metadata: TraversalMetadata | undefined + + if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { + parentMetadata = metadatas.get( + getPostRecord(item.value.post).reply?.parent?.uri || '', + ) + metadata = getTraversalMetadata({ + item, + parentMetadata, + prevItem: thread.at(i - 1), + nextItem: thread.at(i + 1), + }) + storeTraversalMetadata(metadatas, metadata) + } + + if (item.depth < 0) { + /* + * Parents are ignored until we find the anchor post, then we walk + * _up_ from there. + */ + } else if (item.depth === 0) { + if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) { + threadItems.push(views.threadPostNoUnauthenticated(item)) + } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) { + threadItems.push(views.threadPostNotFound(item)) + } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) { + threadItems.push(views.threadPostBlocked(item)) + } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { + const post = views.threadPost({ + uri: item.uri, + depth: item.depth, + value: item.value, + moderationOpts, + threadgateHiddenReplies, + }) + threadItems.push(post) + + parentTraversal: for (let pi = i - 1; pi >= 0; pi--) { + const parent = thread[pi] + + if ( + AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value) + ) { + const post = views.threadPostNoUnauthenticated(parent) + post.ui = getThreadPostNoUnauthenticatedUI({ + depth: parent.depth, + // ignore for now + // prevItemDepth: thread[pi - 1]?.depth, + nextItemDepth: thread[pi + 1]?.depth, + }) + threadItems.unshift(post) + // for now, break parent traversal at first no-unauthed + break parentTraversal + } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) { + threadItems.unshift(views.threadPostNotFound(parent)) + break parentTraversal + } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) { + threadItems.unshift(views.threadPostBlocked(parent)) + break parentTraversal + } else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) { + threadItems.unshift( + views.threadPost({ + uri: parent.uri, + depth: parent.depth, + value: parent.value, + moderationOpts, + threadgateHiddenReplies, + }), + ) + } + } + } + } 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.value) || + AppBskyUnspeccedDefs.isThreadItemNotFound(item.value) || + AppBskyUnspeccedDefs.isThreadItemBlocked(item.value) + + if (shouldBreak) { + const branch = getBranch(thread, i, item.depth) + // could insert tombstone + i = branch.end + continue traversal + } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { + if (parentMetadata) { + /* + * Set this value before incrementing the parent's repliesSeenCounter + */ + metadata!.replyIndex = parentMetadata.repliesIndexCounter + // Increment the parent's repliesIndexCounter + parentMetadata.repliesIndexCounter += 1 + } + + const post = views.threadPost({ + uri: item.uri, + depth: item.depth, + value: item.value, + moderationOpts, + threadgateHiddenReplies, + }) + + if (!post.isBlurred || skipModerationHandling) { + /* + * Not moderated, need to insert it + */ + threadItems.push(post) + + /* + * Update seen reply count of parent + */ + if (parentMetadata) { + parentMetadata.repliesSeenCounter += 1 + } + } else { + /* + * Moderated in some way, we're going to walk children + */ + const parent = post + const parentIsTopLevelReply = parent.depth === 1 + // get sub tree + const branch = getBranch(thread, i, item.depth) + + if (parentIsTopLevelReply) { + // push branch anchor into sorted array + otherThreadItems.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.value)) { + const childParentMetadata = metadatas.get( + getPostRecord(child.value.post).reply?.parent?.uri || '', + ) + const childMetadata = getTraversalMetadata({ + item: child, + prevItem: thread[ci - 1], + nextItem: thread[ci + 1], + parentMetadata: childParentMetadata, + }) + storeTraversalMetadata(metadatas, childMetadata) + if (childParentMetadata) { + /* + * Set this value before incrementing the parent's repliesIndexCounter + */ + childMetadata!.replyIndex = + childParentMetadata.repliesIndexCounter + childParentMetadata.repliesIndexCounter += 1 + } + + const childPost = views.threadPost({ + uri: child.uri, + depth: child.depth, + value: child.value, + moderationOpts, + threadgateHiddenReplies, + }) + + /* + * If a child is moderated in any way, drop it an its sub-branch + * entirely. To reveal these, the user must navigate to the + * parent post directly. + */ + if (childPost.isBlurred) { + ci = getBranch(thread, ci, child.depth).end + } else { + otherThreadItems.push(childPost) + + if (childParentMetadata) { + childParentMetadata.repliesSeenCounter += 1 + } + } + } else { + /* + * Drop the rest of the branch if we hit anything unexpected + */ + break + } + } + } + + /* + * Skip to next branch + */ + i = branch.end + continue traversal + } + } + } + } + + /* + * Both `threadItems` and `otherThreadItems` now need to be traversed again to fully compute + * UI state based on collected metadata. These arrays will be muted in situ. + */ + for (const subset of [threadItems, otherThreadItems]) { + for (let i = 0; i < subset.length; i++) { + const item = subset[i] + const prevItem = subset.at(i - 1) + const nextItem = subset.at(i + 1) + + if (item.type === 'threadPost') { + const metadata = metadatas.get(item.uri) + + if (metadata) { + if (metadata.parentMetadata) { + /* + * Track what's before/after now that we've applied moderation + */ + if (prevItem?.type === 'threadPost') + metadata.prevItemDepth = prevItem?.depth + if (nextItem?.type === 'threadPost') + metadata.nextItemDepth = nextItem?.depth + + /* + * We can now officially calculate `isLastSibling` and `isLastChild` + * based on the actual data that we've seen. + */ + metadata.isLastSibling = + metadata.replyIndex === + metadata.parentMetadata.repliesSeenCounter - 1 + metadata.isLastChild = + metadata.nextItemDepth === undefined || + metadata.nextItemDepth <= metadata.depth + + /* + * If this is the last sibling, it's implicitly part of the last + * branch of this sub-tree. + */ + if (metadata.isLastSibling) { + metadata.isPartOfLastBranchFromDepth = metadata.depth + + /** + * If the parent is part of the last branch of the sub-tree, so is the child. + */ + if (metadata.parentMetadata.isPartOfLastBranchFromDepth) { + metadata.isPartOfLastBranchFromDepth = + metadata.parentMetadata.isPartOfLastBranchFromDepth + } + } + + /* + * If this is the last sibling, and the parent has unhydrated replies, + * at some point down the line we will need to show a "read more". + */ + if ( + metadata.parentMetadata.repliesUnhydrated > 0 && + metadata.isLastSibling + ) { + metadata.upcomingParentReadMore = metadata.parentMetadata + } + + /* + * Copy in the parent's upcoming read more, if it exists. Once we + * reach the bottom, we'll insert a "read more" + */ + if (metadata.parentMetadata.upcomingParentReadMore) { + metadata.upcomingParentReadMore = + metadata.parentMetadata.upcomingParentReadMore + } + + /* + * Copy in the parent's skipped indents + */ + metadata.skippedIndentIndices = new Set([ + ...metadata.parentMetadata.skippedIndentIndices, + ]) + + /** + * If this is the last sibling, and the parent has no unhydrated + * replies, then we know we can skip an indent line. + */ + if ( + metadata.parentMetadata.repliesUnhydrated <= 0 && + metadata.isLastSibling + ) { + /** + * Depth is 2 more than the 0-index of the indent calculation + * bc of how we render these. So instead of handling that in the + * component, we just adjust that back to 0-index here. + */ + metadata.skippedIndentIndices.add(item.depth - 2) + } + } + + /* + * If this post has unhydrated replies, and it is the last child, then + * it itself needs a "read more" + */ + if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) { + metadata.precedesChildReadMore = true + subset.splice(i + 1, 0, views.readMore(metadata)) + i++ // skip next iteration + } + + /* + * Tree-view only. + * + * If there's an upcoming parent read more, this branch is part of the + * last branch of the sub-tree, and the item itself is the last child, + * insert the parent "read more". + */ + if ( + view === 'tree' && + metadata.upcomingParentReadMore && + metadata.isPartOfLastBranchFromDepth === + metadata.upcomingParentReadMore.depth && + metadata.isLastChild + ) { + subset.splice( + i + 1, + 0, + views.readMore(metadata.upcomingParentReadMore), + ) + i++ + } + + /** + * Only occurs for the first item in the thread, which may have + * additional parents not included in this request. + */ + if (item.value.moreParents) { + metadata.followsReadMoreUp = true + subset.splice(i, 0, views.readMoreUp(metadata)) + i++ + } + + /* + * Calculate the final UI state for the thread item. + */ + item.ui = getThreadPostUI(metadata) + } + } + } + } + + return { + threadItems, + otherThreadItems, + } +} + +export function buildThread({ + threadItems, + otherThreadItems, + serverOtherThreadItems, + isLoading, + hasSession, + otherItemsVisible, + hasOtherThreadItems, + showOtherItems, +}: { + threadItems: ThreadItem[] + otherThreadItems: ThreadItem[] + serverOtherThreadItems: ThreadItem[] + isLoading: boolean + hasSession: boolean + otherItemsVisible: boolean + hasOtherThreadItems: boolean + showOtherItems: () => void +}) { + /** + * `threadItems` is memoized here, so don't mutate it directly. + */ + const items = [...threadItems] + + if (isLoading) { + const anchorPost = items.at(0) + const hasAnchorFromCache = anchorPost && anchorPost.type === 'threadPost' + const skeletonReplies = hasAnchorFromCache + ? anchorPost.value.post.replyCount ?? 4 + : 4 + + if (!items.length) { + items.push( + views.skeleton({ + key: 'anchor-skeleton', + item: 'anchor', + }), + ) + } + + if (hasSession) { + // we might have this from cache + const replyDisabled = + hasAnchorFromCache && + anchorPost.value.post.viewer?.replyDisabled === true + + if (hasAnchorFromCache) { + if (!replyDisabled) { + items.push({ + type: 'replyComposer', + key: 'replyComposer', + }) + } + } else { + items.push( + views.skeleton({ + key: 'replyComposer', + item: 'replyComposer', + }), + ) + } + } + + for (let i = 0; i < skeletonReplies; i++) { + items.push( + views.skeleton({ + key: `anchor-skeleton-reply-${i}`, + item: 'reply', + }), + ) + } + } else { + for (let i = 0; i < items.length; i++) { + const item = items[i] + if ( + item.type === 'threadPost' && + item.depth === 0 && + !item.value.post.viewer?.replyDisabled && + hasSession + ) { + items.splice(i + 1, 0, { + type: 'replyComposer', + key: 'replyComposer', + }) + break + } + } + + if (otherThreadItems.length || hasOtherThreadItems) { + if (otherItemsVisible) { + items.push(...otherThreadItems) + items.push(...serverOtherThreadItems) + } else { + items.push({ + type: 'showOtherReplies', + key: 'showOtherReplies', + onPress: showOtherItems, + }) + } + } + } + + return items +} + +/** + * 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) + * └─┬ anchor ──────── (0, 0) + * ├─── branch ───── (1, 1) + * ├──┬ branch ───── (2, 1) (start) + * │ ├──┬ leaf ──── (3, 2) + * │ │ └── leaf ── (4, 3) + * │ └─── leaf ──── (5, 2) (end) + * ├─── branch ───── (6, 1) + * └─── branch ───── (7, 1) + * + * const { start: 2, end: 5, length: 3 } = getBranch(items, 2, 1) + */ +export function getBranch( + thread: ApiThreadItem[], + branchStartIndex: number, + branchStartDepth: number, +) { + let end = branchStartIndex + + for (let ci = branchStartIndex + 1; ci < thread.length; ci++) { + const next = thread[ci] + if (next.depth > branchStartDepth) { + end = ci + } else { + end = ci - 1 + break + } + } + + return { + start: branchStartIndex, + end, + length: end - branchStartIndex, + } +} diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts new file mode 100644 index 0000000000..2f370b0ab7 --- /dev/null +++ b/src/state/queries/usePostThread/types.ts @@ -0,0 +1,227 @@ +import { + type AppBskyFeedDefs, + type AppBskyFeedPost, + type AppBskyFeedThreadgate, + type AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadOtherV2, + type AppBskyUnspeccedGetPostThreadV2, + type ModerationDecision, +} from '@atproto/api' + +export type ApiThreadItem = + | AppBskyUnspeccedGetPostThreadV2.ThreadItem + | AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem + +export const postThreadQueryKeyRoot = 'post-thread-v2' as const + +export const createPostThreadQueryKey = (props: PostThreadParams) => + [postThreadQueryKeyRoot, props] as const + +export const createPostThreadOtherQueryKey = ( + props: Omit & { + anchor?: string + }, +) => [postThreadQueryKeyRoot, 'other', props] as const + +export type PostThreadParams = Pick< + AppBskyUnspeccedGetPostThreadV2.QueryParams, + 'sort' | 'prioritizeFollowedUsers' +> & { + anchor?: string + view: 'tree' | 'linear' +} + +export type UsePostThreadQueryResult = { + hasOtherReplies: boolean + thread: AppBskyUnspeccedGetPostThreadV2.ThreadItem[] + threadgate?: Omit & { + record: AppBskyFeedThreadgate.Record + } +} + +export type ThreadItem = + | { + type: 'threadPost' + key: string + uri: string + depth: number + value: Omit & { + post: Omit & { + record: AppBskyFeedPost.Record + } + } + isBlurred: boolean + moderation: ModerationDecision + ui: { + isAnchor: boolean + showParentReplyLine: boolean + showChildReplyLine: boolean + indent: number + isLastChild: boolean + skippedIndentIndices: Set + precedesChildReadMore: boolean + } + } + | { + type: 'threadPostNoUnauthenticated' + key: string + uri: string + depth: number + value: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated + ui: { + showParentReplyLine: boolean + showChildReplyLine: boolean + } + } + | { + type: 'threadPostNotFound' + key: string + uri: string + depth: number + value: AppBskyUnspeccedDefs.ThreadItemNotFound + } + | { + type: 'threadPostBlocked' + key: string + uri: string + depth: number + value: AppBskyUnspeccedDefs.ThreadItemBlocked + } + | { + type: 'replyComposer' + key: string + } + | { + type: 'showOtherReplies' + key: string + onPress: () => void + } + | { + /* + * Read more replies, downwards in the thread. + */ + type: 'readMore' + key: string + depth: number + href: string + moreReplies: number + skippedIndentIndices: Set + } + | { + /* + * Read more parents, upwards in the thread. + */ + type: 'readMoreUp' + key: string + href: string + } + | { + type: 'skeleton' + key: string + item: 'anchor' | 'reply' | 'replyComposer' + } + +/** + * Metadata collected while traversing the raw data from the thread response. + * Some values here can be computed immediately, while others need to be + * computed during a second pass over the thread after we know things like + * total number of replies, the reply index, etc. + * + * The idea here is that these values should be objectively true in all cases, + * such that we can use them later — either individually on in composite — to + * drive rendering behaviors. + */ +export type TraversalMetadata = { + /** + * The depth of the post in the reply tree, where 0 is the root post. This is + * calculated on the server. + */ + depth: number + /** + * Indicates if this item is a "read more" link preceding this post that + * continues the thread upwards. + */ + followsReadMoreUp: boolean + /** + * Indicates if the post is the last reply beneath its parent post. + */ + isLastSibling: boolean + /** + * Indicates the post is the end-of-the-line for a given branch of replies. + */ + isLastChild: boolean + /** + * Indicates if the post is the left/lower-most branch of the reply tree. + * Value corresponds to the depth at which this branch started. + */ + isPartOfLastBranchFromDepth?: number + /** + * The depth of the slice immediately following this one, if it exists. + */ + nextItemDepth?: number + /** + * This is a live reference to the parent metadata object. Mutations to this + * are available for later use in children. + */ + parentMetadata?: TraversalMetadata + /** + * Populated during the final traversal of the thread. Denotes whether + * there is a "Read more" link for this item immediately following + * this item. + */ + precedesChildReadMore: boolean + /** + * The depth of the slice immediately preceding this one, if it exists. + */ + prevItemDepth?: number + /** + * Any data needed to be passed along to the "read more" items. Keep this + * trim for better memory usage. + */ + postData: { + uri: string + authorHandle: string + } + /** + * The total number of replies to this post, including those not hydrated + * and returned by the response. + */ + repliesCount: number + /** + * The number of replies to this post not hydrated and returned by the + * response. + */ + repliesUnhydrated: number + /** + * The number of replies that have been seen so far in the traversal. + * Excludes replies that are moderated in some way, since those are not + * "seen" on first load. Use `repliesIndexCounter` for the total number of + * replies that were hydrated in the response. + * + * After traversal, we can use this to calculate if we actually got all the + * replies we expected, or if some were blocked, etc. + */ + repliesSeenCounter: number + /** + * The total number of replies to this post hydrated in this response. Used + * for populating the `replyIndex` of the post by referencing this value on + * the parent. + */ + repliesIndexCounter: number + /** + * The index-0-based index of this reply in the parent post's replies. + */ + replyIndex: number + /** + * Each slice is responsible for rendering reply lines based on its depth. + * This value corresponds to any line indices that can be skipped e.g. + * because there are no further replies below this sub-tree to render. + */ + skippedIndentIndices: Set + /** + * Indicates and stores parent data IF that parent has additional unhydrated + * replies. This value is passed down to children along the left/lower-most + * branch of the tree. When the end is reached, a "read more" is inserted. + */ + upcomingParentReadMore?: TraversalMetadata +} diff --git a/src/state/queries/usePostThread/utils.ts b/src/state/queries/usePostThread/utils.ts new file mode 100644 index 0000000000..b8ab340d87 --- /dev/null +++ b/src/state/queries/usePostThread/utils.ts @@ -0,0 +1,170 @@ +import { + type AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedThreadgate, + AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadV2, + AtUri, +} from '@atproto/api' + +import { + type ApiThreadItem, + type ThreadItem, + type TraversalMetadata, +} from '#/state/queries/usePostThread/types' +import {isDevMode} from '#/storage/hooks/dev-mode' +import * as bsky from '#/types/bsky' + +export function getThreadgateRecord( + view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'], +) { + return bsky.dangerousIsType( + view?.record, + AppBskyFeedThreadgate.isRecord, + ) + ? view?.record + : undefined +} + +export function getRootPostAtUri(post: AppBskyFeedDefs.PostView) { + if ( + bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) + ) { + if (post.record.reply?.root?.uri) { + return new AtUri(post.record.reply.root.uri) + } + } +} + +export function getPostRecord(post: AppBskyFeedDefs.PostView) { + return post.record as AppBskyFeedPost.Record +} + +export function getTraversalMetadata({ + item, + prevItem, + nextItem, + parentMetadata, +}: { + item: ApiThreadItem + prevItem?: ApiThreadItem + nextItem?: ApiThreadItem + parentMetadata?: TraversalMetadata +}): TraversalMetadata { + if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { + throw new Error(`Expected thread item to be a post`) + } + const repliesCount = item.value.post.replyCount || 0 + const repliesUnhydrated = item.value.moreReplies || 0 + const metadata = { + depth: item.depth, + /* + * Unknown until after traversal + */ + isLastChild: false, + /* + * Unknown until after traversal + */ + isLastSibling: false, + /* + * If it's a top level reply, bc we render each top-level branch as a + * separate tree, it's implicitly part of the last branch. For subsequent + * replies, we'll override this after traversal. + */ + isPartOfLastBranchFromDepth: item.depth === 1 ? 1 : undefined, + nextItemDepth: nextItem?.depth, + parentMetadata, + prevItemDepth: prevItem?.depth, + /* + * Unknown until after traversal + */ + precedesChildReadMore: false, + /* + * Unknown until after traversal + */ + followsReadMoreUp: false, + postData: { + uri: item.uri, + authorHandle: item.value.post.author.handle, + }, + repliesCount, + repliesUnhydrated, + repliesSeenCounter: 0, + repliesIndexCounter: 0, + replyIndex: 0, + skippedIndentIndices: new Set(), + } + + if (isDevMode()) { + // @ts-ignore dev only for debugging + metadata.postData.text = getPostRecord(item.value.post).text + } + + return metadata +} + +export function storeTraversalMetadata( + metadatas: Map, + metadata: TraversalMetadata, +) { + metadatas.set(metadata.postData.uri, metadata) + + if (isDevMode()) { + // @ts-ignore dev only for debugging + metadatas.set(metadata.postData.text, metadata) + // @ts-ignore + window.__thread = metadatas + } +} + +export function getThreadPostUI({ + depth, + repliesCount, + prevItemDepth, + isLastChild, + skippedIndentIndices, + repliesSeenCounter, + repliesUnhydrated, + precedesChildReadMore, + followsReadMoreUp, +}: TraversalMetadata): Extract['ui'] { + const isReplyAndHasReplies = + depth > 0 && + repliesCount > 0 && + (repliesCount - repliesUnhydrated === repliesSeenCounter || + repliesSeenCounter > 0) + return { + isAnchor: depth === 0, + showParentReplyLine: + followsReadMoreUp || + (!!prevItemDepth && prevItemDepth !== 0 && prevItemDepth < depth), + showChildReplyLine: depth < 0 || isReplyAndHasReplies, + indent: depth, + /* + * If there are no slices below this one, or the next slice has a depth <= + * than the depth of this post, it's the last child of the reply tree. It + * is not necessarily the last leaf in the parent branch, since it could + * have another sibling. + */ + isLastChild, + skippedIndentIndices, + precedesChildReadMore: precedesChildReadMore ?? false, + } +} + +export function getThreadPostNoUnauthenticatedUI({ + depth, + prevItemDepth, +}: { + depth: number + prevItemDepth?: number + nextItemDepth?: number +}): Extract['ui'] { + return { + showChildReplyLine: depth < 0, + showParentReplyLine: Boolean(prevItemDepth && prevItemDepth < depth), + } +} diff --git a/src/state/queries/usePostThread/views.ts b/src/state/queries/usePostThread/views.ts new file mode 100644 index 0000000000..71acfc77bc --- /dev/null +++ b/src/state/queries/usePostThread/views.ts @@ -0,0 +1,183 @@ +import { + type $Typed, + type AppBskyFeedDefs, + type AppBskyFeedPost, + type AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadV2, + AtUri, + moderatePost, + type ModerationOpts, +} from '@atproto/api' + +import {makeProfileLink} from '#/lib/routes/links' +import { + type ApiThreadItem, + type ThreadItem, + type TraversalMetadata, +} from '#/state/queries/usePostThread/types' + +export function threadPostNoUnauthenticated({ + uri, + depth, + value, +}: ApiThreadItem): Extract { + return { + type: 'threadPostNoUnauthenticated', + key: uri, + uri, + depth, + value: value as AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated, + // @ts-ignore populated by the traversal + ui: {}, + } +} + +export function threadPostNotFound({ + uri, + depth, + value, +}: ApiThreadItem): Extract { + return { + type: 'threadPostNotFound', + key: uri, + uri, + depth, + value: value as AppBskyUnspeccedDefs.ThreadItemNotFound, + } +} + +export function threadPostBlocked({ + uri, + depth, + value, +}: ApiThreadItem): Extract { + return { + type: 'threadPostBlocked', + key: uri, + uri, + depth, + value: value as AppBskyUnspeccedDefs.ThreadItemBlocked, + } +} + +export function threadPost({ + uri, + depth, + value, + moderationOpts, + threadgateHiddenReplies, +}: { + uri: string + depth: number + value: $Typed + moderationOpts: ModerationOpts + threadgateHiddenReplies: Set +}): Extract { + const moderation = moderatePost(value.post, moderationOpts) + const modui = moderation.ui('contentList') + const blurred = modui.blur || modui.filter + const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted' + const hiddenByThreadgate = threadgateHiddenReplies.has(uri) + const isBlurred = hiddenByThreadgate || blurred || muted + return { + type: 'threadPost', + key: uri, + uri, + depth, + value: { + ...value, + /* + * Do not spread anything here, load bearing for post shadow strict + * equality reference checks. + */ + post: value.post as Omit & { + record: AppBskyFeedPost.Record + }, + }, + isBlurred, + moderation, + // @ts-ignore populated by the traversal + ui: {}, + } +} + +export function readMore({ + depth, + repliesUnhydrated, + skippedIndentIndices, + postData, +}: TraversalMetadata): Extract { + const urip = new AtUri(postData.uri) + const href = makeProfileLink( + { + did: urip.host, + handle: postData.authorHandle, + }, + 'post', + urip.rkey, + ) + return { + type: 'readMore' as const, + key: `readMore:${postData.uri}`, + href, + moreReplies: repliesUnhydrated, + depth, + skippedIndentIndices, + } +} + +export function readMoreUp({ + postData, +}: TraversalMetadata): Extract { + const urip = new AtUri(postData.uri) + const href = makeProfileLink( + { + did: urip.host, + handle: postData.authorHandle, + }, + 'post', + urip.rkey, + ) + return { + type: 'readMoreUp' as const, + key: `readMoreUp:${postData.uri}`, + href, + } +} + +export function skeleton({ + key, + item, +}: Omit, 'type'>): Extract< + ThreadItem, + {type: 'skeleton'} +> { + return { + type: 'skeleton', + key, + item, + } +} + +export function postViewToThreadPlaceholder( + post: AppBskyFeedDefs.PostView, +): $Typed< + Omit & { + value: $Typed + } +> { + return { + $type: 'app.bsky.unspecced.getPostThreadV2#threadItem', + uri: post.uri, + depth: 0, // reset to 0 for highlighted post + value: { + $type: 'app.bsky.unspecced.defs#threadItemPost', + post, + opThread: false, + moreParents: false, + moreReplies: 0, + hiddenByThreadgate: false, + mutedByViewer: false, + }, + } +} diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx index ad07333beb..b317942480 100644 --- a/src/state/shell/composer/index.tsx +++ b/src/state/shell/composer/index.tsx @@ -2,6 +2,7 @@ import React from 'react' import { type AppBskyActorDefs, type AppBskyFeedDefs, + type AppBskyUnspeccedGetPostThreadV2, type ModerationDecision, } from '@atproto/api' import {msg} from '@lingui/macro' @@ -24,9 +25,17 @@ export interface ComposerOptsPostRef { moderation?: ModerationDecision } +export type OnPostSuccessData = + | { + replyToUri?: string + posts: AppBskyUnspeccedGetPostThreadV2.ThreadItem[] + } + | undefined + export interface ComposerOpts { replyTo?: ComposerOptsPostRef onPost?: (postUri: string | undefined) => void + onPostSuccess?: (data: OnPostSuccessData) => void quote?: AppBskyFeedDefs.PostView mention?: string // handle of user to mention openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void 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/storage/hooks/dev-mode.ts b/src/storage/hooks/dev-mode.ts index 49eca3bb11..331825c48f 100644 --- a/src/storage/hooks/dev-mode.ts +++ b/src/storage/hooks/dev-mode.ts @@ -5,3 +5,17 @@ export function useDevMode() { return [devMode, setDevMode] as const } + +let cachedIsDevMode: boolean | undefined +/** + * Does not update when toggling dev mode on or off. This util simply retrieves + * the value and caches in memory indefinitely. So after an update, you'll need + * to reload the app so it can pull a fresh value from storage. + */ +export function isDevMode() { + if (__DEV__) return true + if (cachedIsDevMode === undefined) { + cachedIsDevMode = device.get(['devMode']) ?? false + } + return cachedIsDevMode +} diff --git a/src/types/utils.ts b/src/types/utils.ts new file mode 100644 index 0000000000..f64922a1f1 --- /dev/null +++ b/src/types/utils.ts @@ -0,0 +1,5 @@ +export type Literal = T extends A + ? string extends T + ? never + : T + : never diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 42f057803f..f5b29664ab 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -45,6 +45,7 @@ import {type ImagePickerAsset} from 'expo-image-picker' import { AppBskyFeedDefs, type AppBskyFeedGetPostThread, + AppBskyUnspeccedDefs, type BskyAgent, type RichText, } from '@atproto/api' @@ -55,6 +56,7 @@ import {useQueryClient} from '@tanstack/react-query' import * as apilib from '#/lib/api/index' import {EmbeddingDisabledError} from '#/lib/api/resolve' +import {retry} from '#/lib/async/retry' import {until} from '#/lib/async/until' import { MAX_GRAPHEME_LENGTH, @@ -87,7 +89,7 @@ import {useProfileQuery} from '#/state/queries/profile' import {type Gif} from '#/state/queries/tenor' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' -import {type ComposerOpts} from '#/state/shell/composer' +import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo' import { @@ -152,6 +154,7 @@ type Props = ComposerOpts export const ComposePost = ({ replyTo, onPost, + onPostSuccess, quote: initQuote, mention: initMention, openEmojiPicker, @@ -388,8 +391,10 @@ export const ComposePost = ({ setError('') setIsPublishing(true) - let postUri + let postUri: string | undefined + let postSuccessData: OnPostSuccessData try { + logger.info(`composer: posting...`) postUri = ( await apilib.post(agent, queryClient, { thread, @@ -398,16 +403,48 @@ export const ComposePost = ({ langs: toPostLanguages(langPrefs.postLanguage), }) ).uris[0] + + /* + * Wait for app view to have received the post(s). If this fails, it's + * ok, because the post _was_ actually published above. + */ try { - await whenAppViewReady(agent, postUri, res => { - const postedThread = res?.data?.thread - return AppBskyFeedDefs.isThreadViewPost(postedThread) - }) + if (postUri) { + logger.info(`composer: waiting for app view`) + + const posts = await retry( + 5, + _e => true, + async () => { + const res = await agent.app.bsky.unspecced.getPostThreadV2({ + anchor: postUri!, + above: false, + below: thread.posts.length - 1, + branchingFactor: 1, + }) + if (res.data.thread.length !== thread.posts.length) { + throw new Error(`composer: app view is not ready`) + } + if ( + !res.data.thread.every(p => + AppBskyUnspeccedDefs.isThreadItemPost(p.value), + ) + ) { + throw new Error(`composer: app view returned non-post items`) + } + return res.data.thread + }, + 1e3, + ) + postSuccessData = { + replyToUri: replyTo?.uri, + posts, + } + } } catch (waitErr: any) { - logger.error(waitErr, { - message: `Waiting for app view failed`, + logger.info(`composer: waiting for app view failed`, { + safeMessage: waitErr, }) - // Keep going because the post *was* published. } } catch (e: any) { logger.error(e, { @@ -465,12 +502,14 @@ export const ComposePost = ({ quotedThread.post.quoteCount !== initQuote.quoteCount ) { onPost?.(postUri) + onPostSuccess?.(postSuccessData) return true } return false }) } else { onPost?.(postUri) + onPostSuccess?.(postSuccessData) } onClose() Toast.show( @@ -489,6 +528,7 @@ export const ComposePost = ({ langPrefs.postLanguage, onClose, onPost, + onPostSuccess, initQuote, replyTo, setLangPrefs, diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 5bec9ced1a..94cc04f542 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,8 +1,7 @@ import React, {memo, useRef, useState} from 'react' -import {StyleSheet, useWindowDimensions, View} from 'react-native' -import {runOnJS} from 'react-native-reanimated' +import {useWindowDimensions, View} from 'react-native' +import {runOnJS, useAnimatedStyle} from 'react-native-reanimated' import Animated from 'react-native-reanimated' -import {useSafeAreaInsets} from 'react-native-safe-area-context' import { AppBskyFeedDefs, type AppBskyFeedThreadgate, @@ -13,11 +12,9 @@ import {useLingui} from '@lingui/react' import {HITSLOP_10} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' -import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 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' @@ -37,6 +34,7 @@ import { import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences' import {useSession} from '#/state/session' +import {useShellLayout} from '#/state/shell/shell-layout' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {useUnstablePostSource} from '#/state/unstable-post-source' import {List, type ListMethods} from '#/view/com/util/List' @@ -301,11 +299,14 @@ export function PostThread({uri}: {uri: string}) { // maintainVisibleContentPosition and onContentSizeChange // to "hold onto" the correct row instead of the first one. + /* + * This is basically `!!parents.length`, see notes on `isParentLoading` + */ 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 + // https://bsky.app/profile/samuel.bsky.team/post/3kjqhblh6qk2o // Everything is loaded let startIndex = Math.max(0, parents.length - maxParents) @@ -581,6 +582,9 @@ export function PostThread({uri}: {uri: string}) { onEndReached={onEndReached} onEndReachedThreshold={2} onScrollToTop={onScrollToTop} + /** + * @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition + */ maintainVisibleContentPosition={ isNative && hasParents ? MAINTAIN_VISIBLE_CONTENT_POSITION @@ -729,17 +733,16 @@ let ThreadMenu = ({ ThreadMenu = memo(ThreadMenu) function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { - const safeAreaInsets = useSafeAreaInsets() - const fabMinimalShellTransform = useMinimalShellFabTransform() + const {footerHeight} = useShellLayout() + + const animatedStyle = useAnimatedStyle(() => { + return { + bottom: footerHeight.get(), + } + }) + return ( - + ) @@ -904,12 +907,3 @@ function hasBranchingReplies(node?: ThreadNode) { } return true } - -const styles = StyleSheet.create({ - prompt: { - // @ts-ignore web-only - position: isWeb ? 'fixed' : 'absolute', - left: 0, - right: 0, - }, -}) diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx index 40acff3765..f45b16085f 100644 --- a/src/view/com/post-thread/PostThreadComposePrompt.tsx +++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx @@ -1,20 +1,25 @@ -import {View} from 'react-native' +import {type StyleProp, View, type ViewStyle} from 'react-native' +import {LinearGradient} from 'expo-linear-gradient' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {PressableScale} from '#/lib/custom-animations/PressableScale' import {useHaptics} from '#/lib/haptics' +import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder' import {useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' import {UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, ios, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf' +import {transparentifyColor} from '#/alf/util/colorGeneration' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Text} from '#/components/Typography' export function PostThreadComposePrompt({ onPressCompose, + style, }: { onPressCompose: () => void + style?: StyleProp }) { const {currentAccount} = useSession() const {data: profile} = useProfileQuery({did: currentAccount?.did}) @@ -28,29 +33,49 @@ export function PostThreadComposePrompt({ onOut: onHoverOut, } = useInteractionState() + useHideBottomBarBorderForScreen() + return ( - { - onPressCompose() - playHaptic('Light') - }} - onLongPress={ios(() => { - onPressCompose() - playHaptic('Heavy') - })} - onHoverIn={onHoverIn} - onHoverOut={onHoverOut}> - + {!gtMobile && ( + + )} + { + onPressCompose() + playHaptic('Light') + }} + onLongPress={ios(() => { + onPressCompose() + playHaptic('Heavy') + })} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut} style={[ a.flex_row, a.align_center, @@ -58,6 +83,7 @@ export function PostThreadComposePrompt({ a.gap_sm, a.rounded_full, (!gtMobile || hovered) && t.atoms.bg_contrast_25, + native([a.border, t.atoms.border_contrast_low]), a.transition_color, ]}> Write your reply - - + + ) } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 576b195a06..5184047cbb 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -39,6 +39,7 @@ import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' import {useLanguagePrefs} from '#/state/preferences' import {type ThreadPost} from '#/state/queries/post-thread' import {useSession} from '#/state/session' +import {type OnPostSuccessData} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {type PostSource} from '#/state/unstable-post-source' import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' @@ -85,6 +86,7 @@ export function PostThreadItem({ hasPrecedingItem, overrideBlur, onPostReply, + onPostSuccess, hideTopBorder, threadgateRecord, anchorPostSource, @@ -103,6 +105,7 @@ export function PostThreadItem({ hasPrecedingItem: boolean overrideBlur: boolean onPostReply: (postUri: string | undefined) => void + onPostSuccess?: (data: OnPostSuccessData) => void hideTopBorder?: boolean threadgateRecord?: AppBskyFeedThreadgate.Record anchorPostSource?: PostSource @@ -139,6 +142,7 @@ export function PostThreadItem({ hasPrecedingItem={hasPrecedingItem} overrideBlur={overrideBlur} onPostReply={onPostReply} + onPostSuccess={onPostSuccess} hideTopBorder={hideTopBorder} threadgateRecord={threadgateRecord} anchorPostSource={anchorPostSource} @@ -185,6 +189,7 @@ let PostThreadItemLoaded = ({ hasPrecedingItem, overrideBlur, onPostReply, + onPostSuccess, hideTopBorder, threadgateRecord, anchorPostSource, @@ -204,6 +209,7 @@ let PostThreadItemLoaded = ({ hasPrecedingItem: boolean overrideBlur: boolean onPostReply: (postUri: string | undefined) => void + onPostSuccess?: (data: OnPostSuccessData) => void hideTopBorder?: boolean threadgateRecord?: AppBskyFeedThreadgate.Record anchorPostSource?: PostSource @@ -298,6 +304,7 @@ let PostThreadItemLoaded = ({ moderation, }, onPost: onPostReply, + onPostSuccess: onPostSuccess, }) } diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 1bad9b6cdf..cc611e0d63 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,28 +1,38 @@ -import React from 'react' +import {useCallback} from 'react' import {useFocusEffect} from '@react-navigation/native' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useGate} from '#/lib/statsig/statsig' import {makeRecordUri} from '#/lib/strings/url-helpers' import {useSetMinimalShellMode} from '#/state/shell' import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread' +import {PostThread} from '#/screens/PostThread' import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps export function PostThreadScreen({route}: Props) { const setMinimalShellMode = useSetMinimalShellMode() + const gate = useGate() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) useFocusEffect( - React.useCallback(() => { + useCallback(() => { setMinimalShellMode(false) }, [setMinimalShellMode]), ) return ( - + {gate('post_threads_v2_unspecced') || __DEV__ ? ( + + ) : ( + + )} ) } diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx index 8b53f40416..393b8f80e6 100644 --- a/src/view/shell/Composer.ios.tsx +++ b/src/view/shell/Composer.ios.tsx @@ -37,6 +37,7 @@ export function Composer({}: {winHeight: number}) { cancelRef={ref} replyTo={state?.replyTo} onPost={state?.onPost} + onPostSuccess={state?.onPostSuccess} quote={state?.quote} mention={state?.mention} text={state?.text} diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index e40c3528b9..a17de6163d 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -49,6 +49,7 @@ export function Composer({winHeight}: {winHeight: number}) { { @@ -146,7 +148,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { style={[ styles.bottomBar, pal.view, - pal.border, + hideBorder ? {borderColor: pal.view.backgroundColor} : pal.border, {paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)}, footerMinimalShellTransform, ]} diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index 7a320cb438..8dce85cd17 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -5,16 +5,18 @@ import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigationState} from '@react-navigation/native' +import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder' import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform' import {getCurrentRoute, isTab} from '#/lib/routes/helpers' import {makeProfileLink} from '#/lib/routes/links' -import {CommonNavigatorParams} from '#/lib/routes/types' +import {type CommonNavigatorParams} from '#/lib/routes/types' import {useGate} from '#/lib/statsig/statsig' import {useHomeBadge} from '#/state/home-badge' import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations' import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useSession} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {useShellLayout} from '#/state/shell/shell-layout' import {useCloseAllActiveElements} from '#/state/util' import {Link} from '#/view/com/util/Link' import {Logo} from '#/view/icons/Logo' @@ -49,6 +51,8 @@ export function BottomBarWeb() { const footerMinimalShellTransform = useMinimalShellFooterTransform() const {requestSwitchToAccount} = useLoggedOutViewControls() const closeAllActiveElements = useCloseAllActiveElements() + const {footerHeight} = useShellLayout() + const hideBorder = useHideBottomBarBorder() const iconWidth = 26 const unreadMessageCount = useUnreadMessageCount() @@ -74,9 +78,12 @@ export function BottomBarWeb() { styles.bottomBar, styles.bottomBarWeb, t.atoms.bg, - t.atoms.border_contrast_low, + hideBorder + ? {borderColor: t.atoms.bg.backgroundColor} + : t.atoms.border_contrast_low, footerMinimalShellTransform, - ]}> + ]} + onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}> {hasSession ? ( <>