diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/post-thread/PostThread.tsx | 910 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadComposePrompt.tsx | 95 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadFollowBtn.tsx | 139 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 1036 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadLoadMore.tsx | 65 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadShowHiddenReplies.tsx | 62 | ||||
-rw-r--r-- | src/view/screens/DebugMod.tsx | 53 | ||||
-rw-r--r-- | src/view/screens/PostThread.tsx | 9 |
8 files changed, 31 insertions, 2338 deletions
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx deleted file mode 100644 index bbf9f4a20..000000000 --- a/src/view/com/post-thread/PostThread.tsx +++ /dev/null @@ -1,910 +0,0 @@ -import React, {memo, useRef, useState} from 'react' -import {useWindowDimensions, View} from 'react-native' -import {runOnJS, useAnimatedStyle} from 'react-native-reanimated' -import Animated from 'react-native-reanimated' -import { - AppBskyFeedDefs, - type AppBskyFeedThreadgate, - moderatePost, -} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {HITSLOP_10} from '#/lib/constants' -import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' -import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {useSetTitle} from '#/lib/hooks/useSetTitle' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -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, - sortThread, - type ThreadBlocked, - type ThreadModerationCache, - type ThreadNode, - type ThreadNotFound, - type ThreadPost, - usePostThreadQuery, -} from '#/state/queries/post-thread' -import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences' -import {usePreferencesQuery} from '#/state/queries/preferences' -import {useSession} from '#/state/session' -import {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' -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonIcon} from '#/components/Button' -import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' -import {Header} from '#/components/Layout' -import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' -import * as Menu from '#/components/Menu' -import {Text} from '#/components/Typography' -import {PostThreadComposePrompt} from './PostThreadComposePrompt' -import {PostThreadItem} from './PostThreadItem' -import {PostThreadLoadMore} from './PostThreadLoadMore' -import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies' - -// FlatList maintainVisibleContentPosition breaks if too many items -// are prepended. This seems to be an optimal number based on *shrug*. -const PARENTS_CHUNK_SIZE = 15 - -const MAINTAIN_VISIBLE_CONTENT_POSITION = { - // We don't insert any elements before the root row while loading. - // So the row we want to use as the scroll anchor is the first row. - minIndexForVisible: 0, -} - -const REPLY_PROMPT = {_reactKey: '__reply__'} -const LOAD_MORE = {_reactKey: '__load_more__'} -const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'} -const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'} - -enum HiddenRepliesState { - Hide, - Show, - ShowAndOverridePostHider, -} - -type YieldedItem = - | ThreadPost - | ThreadBlocked - | ThreadNotFound - | typeof SHOW_HIDDEN_REPLIES - | typeof SHOW_MUTED_REPLIES -type RowItem = - | YieldedItem - // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape. - | typeof REPLY_PROMPT - | typeof LOAD_MORE - -type ThreadSkeletonParts = { - parents: YieldedItem[] - highlightedPost: ThreadNode - replies: YieldedItem[] -} - -const keyExtractor = (item: RowItem) => { - return item._reactKey -} - -export function PostThread({uri}: {uri: string}) { - const {hasSession, currentAccount} = useSession() - const {_} = useLingui() - const t = useTheme() - const {isMobile} = useWebMediaQueries() - const initialNumToRender = useInitialNumToRender() - const {height: windowHeight} = useWindowDimensions() - const [hiddenRepliesState, setHiddenRepliesState] = React.useState( - HiddenRepliesState.Hide, - ) - const headerRef = React.useRef<View | null>(null) - const anchorPostSource = useUnstablePostSource(uri) - const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) - - const {data: preferences} = usePreferencesQuery() - const { - isFetching, - isError: isThreadError, - error: threadError, - refetch, - data: {thread, threadgate} = {}, - dataUpdatedAt: fetchedAt, - } = usePostThreadQuery(uri) - - // The original source of truth for these are the server settings. - const serverPrefs = preferences?.threadViewPrefs - const serverPrioritizeFollowedUsers = - serverPrefs?.prioritizeFollowedUsers ?? true - const serverTreeViewEnabled = serverPrefs?.lab_treeViewEnabled ?? false - const serverSortReplies = serverPrefs?.sort ?? 'hotness' - - // However, we also need these to work locally for PWI (without persistence). - // So we're mirroring them locally. - const prioritizeFollowedUsers = serverPrioritizeFollowedUsers - const [treeViewEnabled, setTreeViewEnabled] = useState(serverTreeViewEnabled) - const [sortReplies, setSortReplies] = useState(serverSortReplies) - - // We'll reset the local state if new server state flows down to us. - const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs) - if (prevServerPrefs !== serverPrefs) { - setPrevServerPrefs(serverPrefs) - setTreeViewEnabled(serverTreeViewEnabled) - setSortReplies(serverSortReplies) - } - - // And we'll update the local state when mutating the server prefs. - const {mutate: mutateThreadViewPrefs} = useSetThreadViewPreferencesMutation() - function updateTreeViewEnabled(newTreeViewEnabled: boolean) { - setTreeViewEnabled(newTreeViewEnabled) - if (hasSession) { - mutateThreadViewPrefs({lab_treeViewEnabled: newTreeViewEnabled}) - } - } - function updateSortReplies(newSortReplies: string) { - setSortReplies(newSortReplies) - if (hasSession) { - mutateThreadViewPrefs({sort: newSortReplies}) - } - } - - const treeView = React.useMemo( - () => treeViewEnabled && hasBranchingReplies(thread), - [treeViewEnabled, thread], - ) - - const rootPost = thread?.type === 'post' ? thread.post : undefined - const rootPostRecord = thread?.type === 'post' ? thread.record : undefined - const threadgateRecord = threadgate?.record as - | AppBskyFeedThreadgate.Record - | undefined - const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ - threadgateRecord, - }) - - const moderationOpts = useModerationOpts() - const isNoPwi = React.useMemo(() => { - const mod = - rootPost && moderationOpts - ? moderatePost(rootPost, moderationOpts) - : undefined - return !!mod - ?.ui('contentList') - .blurs.find( - cause => - cause.type === 'label' && - cause.labelDef.identifier === '!no-unauthenticated', - ) - }, [rootPost, moderationOpts]) - - // Values used for proper rendering of parents - const ref = useRef<ListMethods>(null) - const highlightedPostRef = useRef<View | null>(null) - const [maxParents, setMaxParents] = React.useState( - isWeb ? Infinity : PARENTS_CHUNK_SIZE, - ) - const [maxReplies, setMaxReplies] = React.useState(50) - - useSetTitle( - rootPost && !isNoPwi - ? `${sanitizeDisplayName( - rootPost.author.displayName || `@${rootPost.author.handle}`, - )}: "${rootPostRecord!.text}"` - : '', - ) - - // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed. - // This ensures that the first render contains no parents--even if they are already available in the cache. - // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen. - // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead. - const [deferParents, setDeferParents] = React.useState(isNative) - - const currentDid = currentAccount?.did - const threadModerationCache = React.useMemo(() => { - const cache: ThreadModerationCache = new WeakMap() - if (thread && moderationOpts) { - fillThreadModerationCache(cache, thread, moderationOpts) - } - return cache - }, [thread, moderationOpts]) - - const [justPostedUris, setJustPostedUris] = React.useState( - () => new Set<string>(), - ) - - const [fetchedAtCache] = React.useState(() => new Map<string, number>()) - const [randomCache] = React.useState(() => new Map<string, number>()) - const skeleton = React.useMemo(() => { - if (!thread) return null - return createThreadSkeleton( - sortThread( - thread, - { - // Prefer local state as the source of truth. - sort: sortReplies, - lab_treeViewEnabled: treeViewEnabled, - prioritizeFollowedUsers, - }, - threadModerationCache, - currentDid, - justPostedUris, - threadgateHiddenReplies, - fetchedAtCache, - fetchedAt, - randomCache, - ), - currentDid, - treeView, - threadModerationCache, - hiddenRepliesState !== HiddenRepliesState.Hide, - threadgateHiddenReplies, - ) - }, [ - thread, - prioritizeFollowedUsers, - sortReplies, - treeViewEnabled, - currentDid, - treeView, - threadModerationCache, - hiddenRepliesState, - justPostedUris, - threadgateHiddenReplies, - fetchedAtCache, - fetchedAt, - randomCache, - ]) - - const error = React.useMemo(() => { - if (AppBskyFeedDefs.isNotFoundPost(thread)) { - return { - title: _(msg`Post not found`), - message: _(msg`The post may have been deleted.`), - } - } else if (skeleton?.highlightedPost.type === 'blocked') { - return { - title: _(msg`Post hidden`), - message: _( - msg`You have blocked the author or you have been blocked by the author.`, - ), - } - } else if (threadError?.message.startsWith('Post not found')) { - return { - title: _(msg`Post not found`), - message: _(msg`The post may have been deleted.`), - } - } else if (isThreadError) { - return { - message: threadError ? cleanError(threadError) : undefined, - } - } - - return null - }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) - - // construct content - const posts = React.useMemo(() => { - if (!skeleton) return [] - - const {parents, highlightedPost, replies} = skeleton - let arr: RowItem[] = [] - if (highlightedPost.type === 'post') { - // We want to wait for parents to load before rendering. - // If you add something here, you'll need to update both - // maintainVisibleContentPosition and onContentSizeChange - // to "hold onto" the correct row instead of the first one. - - /* - * 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/samuel.bsky.team/post/3kjqhblh6qk2o - - // Everything is loaded - let startIndex = Math.max(0, parents.length - maxParents) - for (let i = startIndex; i < parents.length; i++) { - arr.push(parents[i]) - } - } - arr.push(highlightedPost) - if (!highlightedPost.post.viewer?.replyDisabled) { - arr.push(REPLY_PROMPT) - } - for (let i = 0; i < replies.length; i++) { - arr.push(replies[i]) - if (i === maxReplies) { - break - } - } - } - return arr - }, [skeleton, deferParents, maxParents, maxReplies]) - - // This is only used on the web to keep the post in view when its parents load. - // On native, we rely on `maintainVisibleContentPosition` instead. - const didAdjustScrollWeb = useRef<boolean>(false) - const onContentSizeChangeWeb = React.useCallback(() => { - // only run once - if (didAdjustScrollWeb.current) { - return - } - // wait for loading to finish - if (thread?.type === 'post' && !!thread.parent) { - // Measure synchronously to avoid a layout jump. - const postNode = highlightedPostRef.current - const headerNode = headerRef.current - if (postNode && headerNode) { - let pageY = (postNode as any as Element).getBoundingClientRect().top - pageY -= (headerNode as any as Element).getBoundingClientRect().height - pageY = Math.max(0, pageY) - ref.current?.scrollToOffset({ - animated: false, - offset: pageY, - }) - } - didAdjustScrollWeb.current = true - } - }, [thread]) - - // On native, we reveal parents in chunks. Although they're all already - // loaded and FlatList already has its own virtualization, unfortunately FlatList - // has a bug that causes the content to jump around if too many items are getting - // prepended at once. It also jumps around if items get prepended during scroll. - // To work around this, we prepend rows after scroll bumps against the top and rests. - const needsBumpMaxParents = React.useRef(false) - const onStartReached = React.useCallback(() => { - if (skeleton?.parents && maxParents < skeleton.parents.length) { - needsBumpMaxParents.current = true - } - }, [maxParents, skeleton?.parents]) - const bumpMaxParentsIfNeeded = React.useCallback(() => { - if (!isNative) { - return - } - if (needsBumpMaxParents.current) { - needsBumpMaxParents.current = false - setMaxParents(n => n + PARENTS_CHUNK_SIZE) - } - }, []) - const onScrollToTop = bumpMaxParentsIfNeeded - const onMomentumEnd = React.useCallback(() => { - 'worklet' - runOnJS(bumpMaxParentsIfNeeded)() - }, [bumpMaxParentsIfNeeded]) - - const onEndReached = React.useCallback(() => { - if (isFetching || posts.length < maxReplies) return - setMaxReplies(prev => prev + 50) - }, [isFetching, maxReplies, posts.length]) - - const onPostReply = React.useCallback( - (postUri: string | undefined) => { - refetch() - if (postUri) { - setJustPostedUris(set => { - const nextSet = new Set(set) - nextSet.add(postUri) - return nextSet - }) - } - }, - [refetch], - ) - - const {openComposer} = useOpenComposer() - 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, - cid: thread.post.cid, - text: thread.record.text, - author: thread.post.author, - embed: thread.post.embed, - moderation: threadModerationCache.get(thread), - langs: thread.record.langs, - }, - onPost: onPostReply, - }) - }, [ - openComposer, - thread, - onPostReply, - threadModerationCache, - anchorPostSource, - feedFeedback, - ]) - - const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled - const hasParents = - skeleton?.highlightedPost?.type === 'post' && - (skeleton.highlightedPost.ctx.isParentLoading || - Boolean(skeleton?.parents && skeleton.parents.length > 0)) - - const renderItem = ({item, index}: {item: RowItem; index: number}) => { - if (item === REPLY_PROMPT && hasSession) { - return ( - <View> - {!isMobile && ( - <PostThreadComposePrompt onPressCompose={onReplyToAnchor} /> - )} - </View> - ) - } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { - return ( - <PostThreadShowHiddenReplies - type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'} - onPress={() => - setHiddenRepliesState( - item === SHOW_HIDDEN_REPLIES - ? HiddenRepliesState.Show - : HiddenRepliesState.ShowAndOverridePostHider, - ) - } - hideTopBorder={index === 0} - /> - ) - } else if (isThreadNotFound(item)) { - return ( - <View - style={[ - a.p_lg, - index !== 0 && a.border_t, - t.atoms.border_contrast_low, - t.atoms.bg_contrast_25, - ]}> - <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> - <Trans>Deleted post.</Trans> - </Text> - </View> - ) - } else if (isThreadBlocked(item)) { - return ( - <View - style={[ - a.p_lg, - index !== 0 && a.border_t, - t.atoms.border_contrast_low, - t.atoms.bg_contrast_25, - ]}> - <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> - <Trans>Blocked post.</Trans> - </Text> - </View> - ) - } else if (isThreadPost(item)) { - const prev = isThreadPost(posts[index - 1]) - ? (posts[index - 1] as ThreadPost) - : undefined - const next = isThreadPost(posts[index + 1]) - ? (posts[index + 1] as ThreadPost) - : undefined - const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth - const showParentReplyLine = - (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1 - const hasUnrevealedParents = - index === 0 && skeleton?.parents && maxParents < skeleton.parents.length - - if (!treeView && prev && item.ctx.hasMoreSelfThread) { - return <PostThreadLoadMore post={prev.post} /> - } - - return ( - <View - ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} - onLayout={deferParents ? () => setDeferParents(false) : undefined}> - <PostThreadItem - post={item.post} - record={item.record} - threadgateRecord={threadgateRecord ?? undefined} - moderation={threadModerationCache.get(item)} - treeView={treeView} - depth={item.ctx.depth} - prevPost={prev} - nextPost={next} - isHighlightedPost={item.ctx.isHighlightedPost} - hasMore={item.ctx.hasMore} - showChildReplyLine={showChildReplyLine} - showParentReplyLine={showParentReplyLine} - hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents} - overrideBlur={ - hiddenRepliesState === - HiddenRepliesState.ShowAndOverridePostHider && - item.ctx.depth > 0 - } - onPostReply={onPostReply} - hideTopBorder={index === 0 && !item.ctx.isParentLoading} - anchorPostSource={anchorPostSource} - /> - </View> - ) - } - return null - } - - if (!thread || !preferences || error) { - return ( - <ListMaybePlaceholder - isLoading={!error} - isError={Boolean(error)} - noEmpty - onRetry={refetch} - errorTitle={error?.title} - errorMessage={error?.message} - /> - ) - } - - return ( - <> - <Header.Outer headerRef={headerRef}> - <Header.BackButton /> - <Header.Content> - <Header.TitleText> - <Trans context="description">Post</Trans> - </Header.TitleText> - </Header.Content> - <Header.Slot> - <ThreadMenu - sortReplies={sortReplies} - treeViewEnabled={treeViewEnabled} - setSortReplies={updateSortReplies} - setTreeViewEnabled={updateTreeViewEnabled} - /> - </Header.Slot> - </Header.Outer> - - <ScrollProvider onMomentumEnd={onMomentumEnd}> - <List - ref={ref} - data={posts} - renderItem={renderItem} - keyExtractor={keyExtractor} - onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} - onStartReached={onStartReached} - onEndReached={onEndReached} - onEndReachedThreshold={2} - onScrollToTop={onScrollToTop} - /** - * @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition - */ - maintainVisibleContentPosition={ - isNative && hasParents - ? MAINTAIN_VISIBLE_CONTENT_POSITION - : undefined - } - desktopFixedHeight - removeClippedSubviews={isAndroid ? false : undefined} - ListFooterComponent={ - <ListFooter - // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on - // initial render - isFetchingNextPage={isFetching} - error={cleanError(threadError)} - onRetry={refetch} - // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to - // work without causing weird jumps on web or glitches on native - height={windowHeight - 200} - /> - } - initialNumToRender={initialNumToRender} - windowSize={11} - sideBorders={false} - /> - </ScrollProvider> - {isMobile && canReply && hasSession && ( - <MobileComposePrompt onPressReply={onReplyToAnchor} /> - )} - </> - ) -} - -let ThreadMenu = ({ - sortReplies, - treeViewEnabled, - setSortReplies, - setTreeViewEnabled, -}: { - sortReplies: string - treeViewEnabled: boolean - setSortReplies: (newValue: string) => void - setTreeViewEnabled: (newValue: boolean) => void -}): React.ReactNode => { - const {_} = useLingui() - return ( - <Menu.Root> - <Menu.Trigger label={_(msg`Thread options`)}> - {({props}) => ( - <Button - label={_(msg`Thread options`)} - size="small" - variant="ghost" - color="secondary" - shape="round" - hitSlop={HITSLOP_10} - {...props}> - <ButtonIcon icon={SettingsSlider} size="md" /> - </Button> - )} - </Menu.Trigger> - <Menu.Outer> - <Menu.LabelText> - <Trans>Show replies as</Trans> - </Menu.LabelText> - <Menu.Group> - <Menu.Item - label={_(msg`Linear`)} - onPress={() => { - setTreeViewEnabled(false) - }}> - <Menu.ItemText> - <Trans>Linear</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={!treeViewEnabled} /> - </Menu.Item> - <Menu.Item - label={_(msg`Threaded`)} - onPress={() => { - setTreeViewEnabled(true) - }}> - <Menu.ItemText> - <Trans>Threaded</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={treeViewEnabled} /> - </Menu.Item> - </Menu.Group> - <Menu.Divider /> - <Menu.LabelText> - <Trans>Reply sorting</Trans> - </Menu.LabelText> - <Menu.Group> - <Menu.Item - label={_(msg`Hot replies first`)} - onPress={() => { - setSortReplies('hotness') - }}> - <Menu.ItemText> - <Trans>Hot replies first</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={sortReplies === 'hotness'} /> - </Menu.Item> - <Menu.Item - label={_(msg`Oldest replies first`)} - onPress={() => { - setSortReplies('oldest') - }}> - <Menu.ItemText> - <Trans>Oldest replies first</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={sortReplies === 'oldest'} /> - </Menu.Item> - <Menu.Item - label={_(msg`Newest replies first`)} - onPress={() => { - setSortReplies('newest') - }}> - <Menu.ItemText> - <Trans>Newest replies first</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={sortReplies === 'newest'} /> - </Menu.Item> - <Menu.Item - label={_(msg`Most-liked replies first`)} - onPress={() => { - setSortReplies('most-likes') - }}> - <Menu.ItemText> - <Trans>Most-liked replies first</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={sortReplies === 'most-likes'} /> - </Menu.Item> - <Menu.Item - label={_(msg`Random (aka "Poster's Roulette")`)} - onPress={() => { - setSortReplies('random') - }}> - <Menu.ItemText> - <Trans>Random (aka "Poster's Roulette")</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={sortReplies === 'random'} /> - </Menu.Item> - </Menu.Group> - </Menu.Outer> - </Menu.Root> - ) -} -ThreadMenu = memo(ThreadMenu) - -function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { - const {footerHeight} = useShellLayout() - - const animatedStyle = useAnimatedStyle(() => { - return { - bottom: footerHeight.get(), - } - }) - - return ( - <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}> - <PostThreadComposePrompt onPressCompose={onPressReply} /> - </Animated.View> - ) -} - -function isThreadPost(v: unknown): v is ThreadPost { - return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' -} - -function isThreadNotFound(v: unknown): v is ThreadNotFound { - return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found' -} - -function isThreadBlocked(v: unknown): v is ThreadBlocked { - return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked' -} - -function createThreadSkeleton( - node: ThreadNode, - currentDid: string | undefined, - treeView: boolean, - modCache: ThreadModerationCache, - showHiddenReplies: boolean, - threadgateRecordHiddenReplies: Set<string>, -): ThreadSkeletonParts | null { - if (!node) return null - - return { - parents: Array.from(flattenThreadParents(node, !!currentDid)), - highlightedPost: node, - replies: Array.from( - flattenThreadReplies( - node, - currentDid, - treeView, - modCache, - showHiddenReplies, - threadgateRecordHiddenReplies, - ), - ), - } -} - -function* flattenThreadParents( - node: ThreadNode, - hasSession: boolean, -): Generator<YieldedItem, void> { - if (node.type === 'post') { - if (node.parent) { - yield* flattenThreadParents(node.parent, hasSession) - } - if (!node.ctx.isHighlightedPost) { - yield node - } - } else if (node.type === 'not-found') { - yield node - } else if (node.type === 'blocked') { - yield node - } -} - -// The enum is ordered to make them easy to merge -enum HiddenReplyType { - None = 0, - Muted = 1, - Hidden = 2, -} - -function* flattenThreadReplies( - node: ThreadNode, - currentDid: string | undefined, - treeView: boolean, - modCache: ThreadModerationCache, - showHiddenReplies: boolean, - threadgateRecordHiddenReplies: Set<string>, -): Generator<YieldedItem, HiddenReplyType> { - if (node.type === 'post') { - // dont show pwi-opted-out posts to logged out users - if (!currentDid && hasPwiOptOut(node)) { - return HiddenReplyType.None - } - - // handle blurred items - if (node.ctx.depth > 0) { - const modui = modCache.get(node)?.ui('contentList') - if (modui?.blur || modui?.filter) { - if (!showHiddenReplies || node.ctx.depth > 1) { - if ((modui.blurs[0] || modui.filters[0]).type === 'muted') { - return HiddenReplyType.Muted - } - return HiddenReplyType.Hidden - } - } - - if (!showHiddenReplies) { - const hiddenByThreadgate = threadgateRecordHiddenReplies.has( - node.post.uri, - ) - const authorIsViewer = node.post.author.did === currentDid - if (hiddenByThreadgate && !authorIsViewer) { - return HiddenReplyType.Hidden - } - } - } - - if (!node.ctx.isHighlightedPost) { - yield node - } - - if (node.replies?.length) { - let hiddenReplies = HiddenReplyType.None - for (const reply of node.replies) { - let hiddenReply = yield* flattenThreadReplies( - reply, - currentDid, - treeView, - modCache, - showHiddenReplies, - threadgateRecordHiddenReplies, - ) - if (hiddenReply > hiddenReplies) { - hiddenReplies = hiddenReply - } - if (!treeView && !node.ctx.isHighlightedPost) { - break - } - } - - // show control to enable hidden replies - if (node.ctx.depth === 0) { - if (hiddenReplies === HiddenReplyType.Muted) { - yield SHOW_MUTED_REPLIES - } else if (hiddenReplies === HiddenReplyType.Hidden) { - yield SHOW_HIDDEN_REPLIES - } - } - } - } else if (node.type === 'not-found') { - yield node - } else if (node.type === 'blocked') { - yield node - } - return HiddenReplyType.None -} - -function hasPwiOptOut(node: ThreadPost) { - return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') -} - -function hasBranchingReplies(node?: ThreadNode) { - if (!node) { - return false - } - if (node.type !== 'post') { - return false - } - if (!node.replies) { - return false - } - if (node.replies.length === 1) { - return hasBranchingReplies(node.replies[0]) - } - return true -} diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx deleted file mode 100644 index dc0561725..000000000 --- a/src/view/com/post-thread/PostThreadComposePrompt.tsx +++ /dev/null @@ -1,95 +0,0 @@ -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, 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<ViewStyle> -}) { - const {currentAccount} = useSession() - const {data: profile} = useProfileQuery({did: currentAccount?.did}) - const {_} = useLingui() - const {gtMobile} = useBreakpoints() - const t = useTheme() - const playHaptic = useHaptics() - const { - state: hovered, - onIn: onHoverIn, - onOut: onHoverOut, - } = useInteractionState() - - useHideBottomBarBorderForScreen() - - return ( - <View - style={[ - a.px_sm, - gtMobile - ? [a.py_xs, a.border_t, t.atoms.border_contrast_low, t.atoms.bg] - : [a.pb_2xs], - style, - ]}> - {!gtMobile && ( - <LinearGradient - key={t.name} // android does not update when you change the colors. sigh. - start={[0.5, 0]} - end={[0.5, 1]} - colors={[ - transparentifyColor(t.atoms.bg.backgroundColor, 0), - t.atoms.bg.backgroundColor, - ]} - locations={[0.15, 0.4]} - style={[a.absolute, a.inset_0]} - /> - )} - <PressableScale - accessibilityRole="button" - accessibilityLabel={_(msg`Compose reply`)} - accessibilityHint={_(msg`Opens composer`)} - onPress={() => { - onPressCompose() - playHaptic('Light') - }} - onLongPress={ios(() => { - onPressCompose() - playHaptic('Heavy') - })} - onHoverIn={onHoverIn} - onHoverOut={onHoverOut} - style={[ - a.flex_row, - a.align_center, - a.p_sm, - a.gap_sm, - a.rounded_full, - (!gtMobile || hovered) && t.atoms.bg_contrast_25, - native([a.border, t.atoms.border_contrast_low]), - a.transition_color, - ]}> - <UserAvatar - size={24} - avatar={profile?.avatar} - type={profile?.associated?.labeler ? 'labeler' : 'user'} - /> - <Text style={[a.text_md, t.atoms.text_contrast_medium]}> - <Trans>Write your reply</Trans> - </Text> - </PressableScale> - </View> - ) -} diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx deleted file mode 100644 index fc9296cad..000000000 --- a/src/view/com/post-thread/PostThreadFollowBtn.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react' -import {type AppBskyActorDefs} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' - -import {logger} from '#/logger' -import {useProfileShadow} from '#/state/cache/profile-shadow' -import { - useProfileFollowMutationQueue, - useProfileQuery, -} from '#/state/queries/profile' -import {useRequireAuth} from '#/state/session' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useBreakpoints} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' -import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' - -export function PostThreadFollowBtn({did}: {did: string}) { - const {data: profile, isLoading} = useProfileQuery({did}) - - // We will never hit this - the profile will always be cached or loaded above - // but it keeps the typechecker happy - if (isLoading || !profile) return null - - return <PostThreadFollowBtnLoaded profile={profile} /> -} - -function PostThreadFollowBtnLoaded({ - profile: profileUnshadowed, -}: { - profile: AppBskyActorDefs.ProfileViewDetailed -}) { - const navigation = useNavigation() - const {_} = useLingui() - const {gtMobile} = useBreakpoints() - const profile = useProfileShadow(profileUnshadowed) - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( - profile, - 'PostThreadItem', - ) - const requireAuth = useRequireAuth() - - const isFollowing = !!profile.viewer?.following - const isFollowedBy = !!profile.viewer?.followedBy - const [wasFollowing, setWasFollowing] = React.useState<boolean>(isFollowing) - - // This prevents the button from disappearing as soon as we follow. - const showFollowBtn = React.useMemo( - () => !isFollowing || !wasFollowing, - [isFollowing, wasFollowing], - ) - - /** - * We want this button to stay visible even after following, so that the user can unfollow if they want. - * However, we need it to disappear after we push to a screen and then come back. We also need it to - * show up if we view the post while following, go to the profile and unfollow, then come back to the - * post. - * - * We want to update wasFollowing both on blur and on focus so that we hit all these cases. On native, - * we could do this only on focus because the transition animation gives us time to not notice the - * sudden rendering of the button. However, on web if we do this, there's an obvious flicker once the - * button renders. So, we update the state in both cases. - */ - React.useEffect(() => { - const updateWasFollowing = () => { - if (wasFollowing !== isFollowing) { - setWasFollowing(isFollowing) - } - } - - const unsubscribeFocus = navigation.addListener('focus', updateWasFollowing) - const unsubscribeBlur = navigation.addListener('blur', updateWasFollowing) - - return () => { - unsubscribeFocus() - unsubscribeBlur() - } - }, [isFollowing, wasFollowing, navigation]) - - const onPress = React.useCallback(() => { - if (!isFollowing) { - requireAuth(async () => { - try { - await queueFollow() - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to follow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') - } - } - }) - } else { - requireAuth(async () => { - try { - await queueUnfollow() - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to unfollow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') - } - } - }) - } - }, [isFollowing, requireAuth, queueFollow, _, queueUnfollow]) - - if (!showFollowBtn) return null - - return ( - <Button - testID="followBtn" - label={_(msg`Follow ${profile.handle}`)} - onPress={onPress} - size="small" - variant="solid" - color={isFollowing ? 'secondary' : 'secondary_inverted'} - style={[a.rounded_full]}> - {gtMobile && ( - <ButtonIcon - icon={isFollowing ? Check : Plus} - position="left" - size="sm" - /> - )} - <ButtonText> - {!isFollowing ? ( - isFollowedBy ? ( - <Trans>Follow back</Trans> - ) : ( - <Trans>Follow</Trans> - ) - ) : ( - <Trans>Following</Trans> - )} - </ButtonText> - </Button> - ) -} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx deleted file mode 100644 index 679a506b9..000000000 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ /dev/null @@ -1,1036 +0,0 @@ -import {memo, useCallback, useMemo, useState} from 'react' -import { - type GestureResponderEvent, - StyleSheet, - Text as RNText, - View, -} from 'react-native' -import { - AppBskyFeedDefs, - AppBskyFeedPost, - type AppBskyFeedThreadgate, - AtUri, - type ModerationDecision, - 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 {MAX_POST_LINES} from '#/lib/constants' -import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {usePalette} from '#/lib/hooks/usePalette' -import {useTranslate} from '#/lib/hooks/useTranslate' -import {makeProfileLink} from '#/lib/routes/links' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {sanitizeHandle} from '#/lib/strings/handles' -import {countLines} from '#/lib/strings/helpers' -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 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' -import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' -import {Link} from '#/view/com/util/Link' -import {formatCount} from '#/view/com/util/numeric/format' -import {PostMeta} from '#/view/com/util/PostMeta' -import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, useTheme} from '#/alf' -import {colors} from '#/components/Admonition' -import {Button} from '#/components/Button' -import {useInteractionState} from '#/components/hooks/useInteractionState' -import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' -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 {PostHider} from '#/components/moderation/PostHider' -import {type AppModerationCause} from '#/components/Pills' -import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' -import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' -import {PostControls} from '#/components/PostControls' -import * as Prompt from '#/components/Prompt' -import {RichText} from '#/components/RichText' -import {SubtleWebHover} from '#/components/SubtleWebHover' -import {Text} from '#/components/Typography' -import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' -import {WhoCanReply} from '#/components/WhoCanReply' -import * as bsky from '#/types/bsky' - -export function PostThreadItem({ - post, - record, - moderation, - treeView, - depth, - prevPost, - nextPost, - isHighlightedPost, - hasMore, - showChildReplyLine, - showParentReplyLine, - hasPrecedingItem, - overrideBlur, - onPostReply, - onPostSuccess, - hideTopBorder, - threadgateRecord, - anchorPostSource, -}: { - post: AppBskyFeedDefs.PostView - record: AppBskyFeedPost.Record - moderation: ModerationDecision | undefined - treeView: boolean - depth: number - prevPost: ThreadPost | undefined - nextPost: ThreadPost | undefined - isHighlightedPost?: boolean - hasMore?: boolean - showChildReplyLine?: boolean - showParentReplyLine?: boolean - hasPrecedingItem: boolean - overrideBlur: boolean - onPostReply: (postUri: string | undefined) => void - onPostSuccess?: (data: OnPostSuccessData) => void - hideTopBorder?: boolean - threadgateRecord?: AppBskyFeedThreadgate.Record - anchorPostSource?: PostSource -}) { - const postShadowed = usePostShadow(post) - const richText = useMemo( - () => - new RichTextAPI({ - text: record.text, - facets: record.facets, - }), - [record], - ) - if (postShadowed === POST_TOMBSTONE) { - return <PostThreadItemDeleted hideTopBorder={hideTopBorder} /> - } - if (richText && moderation) { - return ( - <PostThreadItemLoaded - // Safeguard from clobbering per-post state below: - key={postShadowed.uri} - post={postShadowed} - prevPost={prevPost} - nextPost={nextPost} - record={record} - richText={richText} - moderation={moderation} - treeView={treeView} - depth={depth} - isHighlightedPost={isHighlightedPost} - hasMore={hasMore} - showChildReplyLine={showChildReplyLine} - showParentReplyLine={showParentReplyLine} - hasPrecedingItem={hasPrecedingItem} - overrideBlur={overrideBlur} - onPostReply={onPostReply} - onPostSuccess={onPostSuccess} - hideTopBorder={hideTopBorder} - threadgateRecord={threadgateRecord} - anchorPostSource={anchorPostSource} - /> - ) - } - return null -} - -function PostThreadItemDeleted({hideTopBorder}: {hideTopBorder?: boolean}) { - const t = useTheme() - return ( - <View - style={[ - t.atoms.bg, - t.atoms.border_contrast_low, - a.p_xl, - a.pl_lg, - a.flex_row, - a.gap_md, - !hideTopBorder && a.border_t, - ]}> - <TrashIcon style={[t.atoms.text]} /> - <Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}> - <Trans>This post has been deleted.</Trans> - </Text> - </View> - ) -} - -let PostThreadItemLoaded = ({ - post, - record, - richText, - moderation, - treeView, - depth, - prevPost, - nextPost, - isHighlightedPost, - hasMore, - showChildReplyLine, - showParentReplyLine, - hasPrecedingItem, - overrideBlur, - onPostReply, - onPostSuccess, - hideTopBorder, - threadgateRecord, - anchorPostSource, -}: { - post: Shadow<AppBskyFeedDefs.PostView> - record: AppBskyFeedPost.Record - richText: RichTextAPI - moderation: ModerationDecision - treeView: boolean - depth: number - prevPost: ThreadPost | undefined - nextPost: ThreadPost | undefined - isHighlightedPost?: boolean - hasMore?: boolean - showChildReplyLine?: boolean - showParentReplyLine?: boolean - hasPrecedingItem: boolean - overrideBlur: boolean - onPostReply: (postUri: string | undefined) => void - onPostSuccess?: (data: OnPostSuccessData) => void - hideTopBorder?: boolean - threadgateRecord?: AppBskyFeedThreadgate.Record - anchorPostSource?: PostSource -}): React.ReactNode => { - const {currentAccount, hasSession} = useSession() - const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) - - const t = useTheme() - const pal = usePalette('default') - const {_, i18n} = useLingui() - const langPrefs = useLanguagePrefs() - const {openComposer} = useOpenComposer() - const [limitLines, setLimitLines] = useState( - () => countLines(richText?.text) >= MAX_POST_LINES, - ) - const shadowedPostAuthor = useProfileShadow(post.author) - const rootUri = 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 itemTitle = _(msg`Post by ${post.author.handle}`) - 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 likesTitle = _(msg`Likes on this post`) - const repostsHref = useMemo(() => { - const urip = new AtUri(post.uri) - return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') - }, [post.uri, post.author]) - const repostsTitle = _(msg`Reposts of this post`) - const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ - threadgateRecord, - }) - const additionalPostAlerts: AppModerationCause[] = useMemo(() => { - const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) - const isControlledByViewer = new AtUri(rootUri).host === currentAccount?.did - return isControlledByViewer && isPostHiddenByThreadgate - ? [ - { - type: 'reply-hidden', - source: {type: 'user', did: currentAccount?.did}, - priority: 6, - }, - ] - : [] - }, [post, currentAccount?.did, threadgateHiddenReplies, rootUri]) - const quotesHref = useMemo(() => { - const urip = new AtUri(post.uri) - return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') - }, [post.uri, post.author]) - const quotesTitle = _(msg`Quotes of this post`) - const onlyFollowersCanReply = !!threadgateRecord?.allow?.find( - rule => rule.$type === 'app.bsky.feed.threadgate#followerRule', - ) - const showFollowButton = - currentAccount?.did !== post.author.did && !onlyFollowersCanReply - - const needsTranslation = useMemo( - () => - Boolean( - langPrefs.primaryLanguage && - !isPostInLanguage(post, [langPrefs.primaryLanguage]), - ), - [post, langPrefs.primaryLanguage], - ) - - const onPressReply = () => { - if (anchorPostSource && isHighlightedPost) { - feedFeedback.sendInteraction({ - item: post.uri, - event: 'app.bsky.feed.defs#interactionReply', - feedContext: anchorPostSource.post.feedContext, - reqId: anchorPostSource.post.reqId, - }) - } - openComposer({ - replyTo: { - uri: post.uri, - cid: post.cid, - text: record.text, - author: post.author, - embed: post.embed, - moderation, - langs: record.langs, - }, - onPost: onPostReply, - onPostSuccess: onPostSuccess, - }) - } - - const onOpenAuthor = () => { - if (anchorPostSource) { - feedFeedback.sendInteraction({ - item: post.uri, - event: 'app.bsky.feed.defs#clickthroughAuthor', - feedContext: anchorPostSource.post.feedContext, - reqId: anchorPostSource.post.reqId, - }) - } - } - - const onOpenEmbed = () => { - if (anchorPostSource) { - feedFeedback.sendInteraction({ - item: post.uri, - event: 'app.bsky.feed.defs#clickthroughEmbed', - feedContext: anchorPostSource.post.feedContext, - reqId: anchorPostSource.post.reqId, - }) - } - } - - const onPressShowMore = useCallback(() => { - setLimitLines(false) - }, [setLimitLines]) - - const {isActive: live} = useActorStatus(post.author) - - const reason = anchorPostSource?.post.reason - const viaRepost = useMemo(() => { - if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { - return { - uri: reason.uri, - cid: reason.cid, - } - } - }, [reason]) - - if (!record) { - return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} /> - } - - if (isHighlightedPost) { - return ( - <> - {rootUri !== post.uri && ( - <View - style={[ - a.pl_lg, - a.flex_row, - a.pb_xs, - {height: a.pt_lg.paddingTop}, - ]}> - <View style={{width: 42}}> - <View - style={[ - styles.replyLine, - a.flex_grow, - {backgroundColor: pal.colors.replyLine}, - ]} - /> - </View> - </View> - )} - - <View - testID={`postThreadItem-by-${post.author.handle}`} - style={[ - a.px_lg, - t.atoms.border_contrast_low, - // root post styles - rootUri === post.uri && [a.pt_lg], - ]}> - <View style={[a.flex_row, a.gap_md, a.pb_md]}> - <PreviewableUserAvatar - size={42} - profile={post.author} - moderation={moderation.ui('avatar')} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - live={live} - onBeforePress={onOpenAuthor} - /> - <View style={[a.flex_1]}> - <View style={[a.flex_row, a.align_center]}> - <Link - style={[a.flex_shrink]} - href={authorHref} - title={authorTitle} - onBeforePress={onOpenAuthor}> - <Text - emoji - style={[ - a.text_lg, - a.font_bold, - a.leading_snug, - a.self_start, - ]} - numberOfLines={1}> - {sanitizeDisplayName( - post.author.displayName || - sanitizeHandle(post.author.handle), - moderation.ui('displayName'), - )} - </Text> - </Link> - - <View style={[{paddingLeft: 3, top: -1}]}> - <VerificationCheckButton - profile={shadowedPostAuthor} - size="md" - /> - </View> - </View> - <Link style={s.flex1} href={authorHref} title={authorTitle}> - <Text - emoji - style={[ - a.text_md, - a.leading_snug, - t.atoms.text_contrast_medium, - ]} - numberOfLines={1}> - {sanitizeHandle(post.author.handle, '@')} - </Text> - </Link> - </View> - {showFollowButton && ( - <View> - <PostThreadFollowBtn did={post.author.did} /> - </View> - )} - </View> - <View style={[a.pb_sm]}> - <LabelsOnMyPost post={post} style={[a.pb_sm]} /> - <ContentHider - modui={moderation.ui('contentView')} - ignoreMute - childContainerStyle={[a.pt_sm]}> - <PostAlerts - modui={moderation.ui('contentView')} - size="lg" - includeMute - style={[a.pb_sm]} - additionalCauses={additionalPostAlerts} - /> - {richText?.text ? ( - <RichText - enableTags - selectable - value={richText} - style={[a.flex_1, a.text_xl]} - authorHandle={post.author.handle} - shouldProxyLinks={true} - /> - ) : undefined} - {post.embed && ( - <View style={[a.py_xs]}> - <Embed - embed={post.embed} - moderation={moderation} - viewContext={PostEmbedViewContext.ThreadHighlighted} - onOpen={onOpenEmbed} - /> - </View> - )} - </ContentHider> - <ExpandedPostDetails - post={post} - record={record} - isThreadAuthor={isThreadAuthor} - needsTranslation={needsTranslation} - /> - {post.repostCount !== 0 || - post.likeCount !== 0 || - post.quoteCount !== 0 ? ( - // Show this section unless we're *sure* it has no engagement. - <View - style={[ - a.flex_row, - a.align_center, - a.gap_lg, - a.border_t, - a.border_b, - a.mt_md, - a.py_md, - t.atoms.border_contrast_low, - ]}> - {post.repostCount != null && post.repostCount !== 0 ? ( - <Link href={repostsHref} title={repostsTitle}> - <Text - testID="repostCount-expanded" - style={[a.text_md, t.atoms.text_contrast_medium]}> - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {formatCount(i18n, post.repostCount)} - </Text>{' '} - <Plural - value={post.repostCount} - one="repost" - other="reposts" - /> - </Text> - </Link> - ) : null} - {post.quoteCount != null && - post.quoteCount !== 0 && - !post.viewer?.embeddingDisabled ? ( - <Link href={quotesHref} title={quotesTitle}> - <Text - testID="quoteCount-expanded" - style={[a.text_md, t.atoms.text_contrast_medium]}> - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {formatCount(i18n, post.quoteCount)} - </Text>{' '} - <Plural - value={post.quoteCount} - one="quote" - other="quotes" - /> - </Text> - </Link> - ) : null} - {post.likeCount != null && post.likeCount !== 0 ? ( - <Link href={likesHref} title={likesTitle}> - <Text - testID="likeCount-expanded" - style={[a.text_md, t.atoms.text_contrast_medium]}> - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {formatCount(i18n, post.likeCount)} - </Text>{' '} - <Plural value={post.likeCount} one="like" other="likes" /> - </Text> - </Link> - ) : null} - </View> - ) : null} - <View - style={[ - a.pt_sm, - a.pb_2xs, - { - marginLeft: -5, - }, - ]}> - <FeedFeedbackProvider value={feedFeedback}> - <PostControls - big - post={post} - record={record} - richText={richText} - onPressReply={onPressReply} - onPostReply={onPostReply} - logContext="PostThreadItem" - threadgateRecord={threadgateRecord} - feedContext={anchorPostSource?.post?.feedContext} - reqId={anchorPostSource?.post?.reqId} - viaRepost={viaRepost} - /> - </FeedFeedbackProvider> - </View> - </View> - </View> - </> - ) - } else { - const isThreadedChild = treeView && depth > 0 - const isThreadedChildAdjacentTop = - isThreadedChild && prevPost?.ctx.depth === depth && depth !== 1 - const isThreadedChildAdjacentBot = - isThreadedChild && nextPost?.ctx.depth === depth - return ( - <PostOuterWrapper - post={post} - depth={depth} - showParentReplyLine={!!showParentReplyLine} - treeView={treeView} - hasPrecedingItem={hasPrecedingItem} - hideTopBorder={hideTopBorder}> - <PostHider - testID={`postThreadItem-by-${post.author.handle}`} - href={postHref} - disabled={overrideBlur} - modui={moderation.ui('contentList')} - iconSize={isThreadedChild ? 24 : 42} - iconStyles={ - isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2} - } - profile={post.author} - interpretFilterAsBlur> - <View - style={{ - flexDirection: 'row', - gap: 10, - paddingLeft: 8, - height: isThreadedChildAdjacentTop ? 8 : 16, - }}> - <View style={{width: 42}}> - {!isThreadedChild && showParentReplyLine && ( - <View - style={[ - styles.replyLine, - { - flexGrow: 1, - backgroundColor: pal.colors.replyLine, - marginBottom: 4, - }, - ]} - /> - )} - </View> - </View> - - <View - style={[ - a.flex_row, - a.px_sm, - a.gap_md, - { - paddingBottom: - showChildReplyLine && !isThreadedChild - ? 0 - : isThreadedChildAdjacentBot - ? 4 - : 8, - }, - ]}> - {/* If we are in threaded mode, the avatar is rendered in PostMeta */} - {!isThreadedChild && ( - <View> - <PreviewableUserAvatar - size={42} - profile={post.author} - moderation={moderation.ui('avatar')} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - live={live} - /> - - {showChildReplyLine && ( - <View - style={[ - styles.replyLine, - { - flexGrow: 1, - backgroundColor: pal.colors.replyLine, - marginTop: 4, - }, - ]} - /> - )} - </View> - )} - - <View style={[a.flex_1]}> - <PostMeta - author={post.author} - moderation={moderation} - timestamp={post.indexedAt} - postHref={postHref} - showAvatar={isThreadedChild} - avatarSize={24} - style={[a.pb_xs]} - /> - <LabelsOnMyPost post={post} style={[a.pb_xs]} /> - <PostAlerts - modui={moderation.ui('contentList')} - style={[a.pb_2xs]} - additionalCauses={additionalPostAlerts} - /> - {richText?.text ? ( - <View style={[a.pb_2xs, a.pr_sm]}> - <RichText - enableTags - value={richText} - style={[a.flex_1, a.text_md]} - numberOfLines={limitLines ? MAX_POST_LINES : undefined} - authorHandle={post.author.handle} - shouldProxyLinks={true} - /> - {limitLines && ( - <ShowMoreTextButton - style={[a.text_md]} - onPress={onPressShowMore} - /> - )} - </View> - ) : undefined} - {post.embed && ( - <View style={[a.pb_xs]}> - <Embed - embed={post.embed} - moderation={moderation} - viewContext={PostEmbedViewContext.Feed} - /> - </View> - )} - <PostControls - post={post} - record={record} - richText={richText} - onPressReply={onPressReply} - logContext="PostThreadItem" - threadgateRecord={threadgateRecord} - /> - </View> - </View> - {hasMore ? ( - <Link - style={[ - styles.loadMore, - { - paddingLeft: treeView ? 8 : 70, - paddingTop: 0, - paddingBottom: treeView ? 4 : 12, - }, - ]} - href={postHref} - title={itemTitle} - noFeedback> - <Text - style={[t.atoms.text_contrast_medium, a.font_bold, a.text_sm]}> - <Trans>More</Trans> - </Text> - <ChevronRightIcon - size="xs" - style={[t.atoms.text_contrast_medium]} - /> - </Link> - ) : undefined} - </PostHider> - </PostOuterWrapper> - ) - } -} -PostThreadItemLoaded = memo(PostThreadItemLoaded) - -function PostOuterWrapper({ - post, - treeView, - depth, - showParentReplyLine, - hasPrecedingItem, - hideTopBorder, - children, -}: React.PropsWithChildren<{ - post: AppBskyFeedDefs.PostView - treeView: boolean - depth: number - showParentReplyLine: boolean - hasPrecedingItem: boolean - hideTopBorder?: boolean -}>) { - const t = useTheme() - const { - state: hover, - onIn: onHoverIn, - onOut: onHoverOut, - } = useInteractionState() - if (treeView && depth > 0) { - return ( - <View - style={[ - a.flex_row, - a.px_sm, - a.flex_row, - t.atoms.border_contrast_low, - styles.cursor, - depth === 1 && a.border_t, - ]} - onPointerEnter={onHoverIn} - onPointerLeave={onHoverOut}> - {Array.from(Array(depth - 1)).map((_, n: number) => ( - <View - key={`${post.uri}-padding-${n}`} - style={[ - a.ml_sm, - t.atoms.border_contrast_low, - { - borderLeftWidth: 2, - paddingLeft: a.pl_sm.paddingLeft - 2, // minus border - }, - ]} - /> - ))} - <View style={a.flex_1}> - <SubtleWebHover - hover={hover} - style={{ - left: (depth === 1 ? 0 : 2) - a.pl_sm.paddingLeft, - right: -a.pr_sm.paddingRight, - }} - /> - {children} - </View> - </View> - ) - } - return ( - <View - onPointerEnter={onHoverIn} - onPointerLeave={onHoverOut} - style={[ - a.border_t, - a.px_sm, - t.atoms.border_contrast_low, - showParentReplyLine && hasPrecedingItem && styles.noTopBorder, - hideTopBorder && styles.noTopBorder, - styles.cursor, - ]}> - <SubtleWebHover hover={hover} /> - {children} - </View> - ) -} - -function ExpandedPostDetails({ - post, - record, - isThreadAuthor, - needsTranslation, -}: { - post: AppBskyFeedDefs.PostView - record: AppBskyFeedPost.Record - isThreadAuthor: boolean - needsTranslation: boolean -}) { - const t = useTheme() - const pal = usePalette('default') - const {_, i18n} = useLingui() - const translate = useTranslate() - const isRootPost = !('reply' in post.record) - const langPrefs = useLanguagePrefs() - - const onTranslatePress = useCallback( - (e: GestureResponderEvent) => { - e.preventDefault() - translate(record.text || '', langPrefs.primaryLanguage) - - if ( - bsky.dangerousIsType<AppBskyFeedPost.Record>( - post.record, - AppBskyFeedPost.isRecord, - ) - ) { - logger.metric( - 'translate', - { - sourceLanguages: post.record.langs ?? [], - targetLanguage: langPrefs.primaryLanguage, - textLength: post.record.text.length, - }, - {statsig: false}, - ) - } - - return false - }, - [translate, record.text, langPrefs, post], - ) - - return ( - <View style={[a.gap_md, a.pt_md, a.align_start]}> - <BackdatedPostIndicator post={post} /> - <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> - <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> - {niceDate(i18n, post.indexedAt)} - </Text> - {isRootPost && ( - <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> - )} - {needsTranslation && ( - <> - <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> - · - </Text> - - <InlineLinkText - // overridden to open an intent on android, but keep - // as anchor tag for accessibility - to={getTranslatorLink(record.text, langPrefs.primaryLanguage)} - label={_(msg`Translate`)} - style={[a.text_sm, pal.link]} - onPress={onTranslatePress}> - <Trans>Translate</Trans> - </InlineLinkText> - </> - )} - </View> - </View> - ) -} - -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<AppBskyFeedPost.Record>( - 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 ( - <> - <Button - label={_(msg`Archived post`)} - accessibilityHint={_( - msg`Shows information about when this post was created`, - )} - onPress={e => { - e.preventDefault() - e.stopPropagation() - control.open() - }}> - {({hovered, pressed}) => ( - <View - style={[ - a.flex_row, - a.align_center, - a.rounded_full, - t.atoms.bg_contrast_25, - (hovered || pressed) && t.atoms.bg_contrast_50, - { - gap: 3, - paddingHorizontal: 6, - paddingVertical: 3, - }, - ]}> - <CalendarClockIcon fill={orange} size="sm" aria-hidden /> - <Text - style={[ - a.text_xs, - a.font_bold, - a.leading_tight, - t.atoms.text_contrast_medium, - ]}> - <Trans>Archived from {niceDate(i18n, createdAt)}</Trans> - </Text> - </View> - )} - </Button> - - <Prompt.Outer control={control}> - <Prompt.TitleText> - <Trans>Archived post</Trans> - </Prompt.TitleText> - <Prompt.DescriptionText> - <Trans> - This post claims to have been created on{' '} - <RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>, - but was first seen by Bluesky on{' '} - <RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>. - </Trans> - </Prompt.DescriptionText> - <Text - style={[ - a.text_md, - a.leading_snug, - t.atoms.text_contrast_high, - a.pb_xl, - ]}> - <Trans> - Bluesky cannot confirm the authenticity of the claimed date. - </Trans> - </Text> - <Prompt.Actions> - <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} /> - </Prompt.Actions> - </Prompt.Outer> - </> - ) -} - -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 '' - } -} - -const styles = StyleSheet.create({ - outer: { - borderTopWidth: StyleSheet.hairlineWidth, - paddingLeft: 8, - }, - noTopBorder: { - borderTopWidth: 0, - }, - meta: { - flexDirection: 'row', - paddingVertical: 2, - }, - metaExpandedLine1: { - paddingVertical: 0, - }, - loadMore: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-start', - gap: 4, - paddingHorizontal: 20, - }, - replyLine: { - width: 2, - marginLeft: 'auto', - marginRight: 'auto', - }, - cursor: { - // @ts-ignore web only - cursor: 'pointer', - }, -}) diff --git a/src/view/com/post-thread/PostThreadLoadMore.tsx b/src/view/com/post-thread/PostThreadLoadMore.tsx deleted file mode 100644 index 27e2ea724..000000000 --- a/src/view/com/post-thread/PostThreadLoadMore.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import * as React from 'react' -import {View} from 'react-native' -import {AppBskyFeedDefs, AtUri} from '@atproto/api' -import {Trans} from '@lingui/macro' - -import {makeProfileLink} from '#/lib/routes/links' -import {atoms as a, useTheme} from '#/alf' -import {Text} from '#/components/Typography' -import {Link} from '../util/Link' -import {UserAvatar} from '../util/UserAvatar' - -export function PostThreadLoadMore({post}: {post: AppBskyFeedDefs.PostView}) { - const t = useTheme() - - const postHref = React.useMemo(() => { - const urip = new AtUri(post.uri) - return makeProfileLink(post.author, 'post', urip.rkey) - }, [post.uri, post.author]) - - return ( - <Link - href={postHref} - style={[a.flex_row, a.align_center, a.py_md, {paddingHorizontal: 14}]} - hoverStyle={[t.atoms.bg_contrast_25]}> - <View style={[a.flex_row]}> - <View - style={{ - alignItems: 'center', - justifyContent: 'center', - width: 34, - height: 34, - borderRadius: 18, - backgroundColor: t.atoms.bg.backgroundColor, - marginRight: -20, - }}> - <UserAvatar - avatar={post.author.avatar} - size={30} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - /> - </View> - <View - style={{ - alignItems: 'center', - justifyContent: 'center', - width: 34, - height: 34, - borderRadius: 18, - backgroundColor: t.atoms.bg.backgroundColor, - }}> - <UserAvatar - avatar={post.author.avatar} - size={30} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - /> - </View> - </View> - <View style={[a.px_sm]}> - <Text style={[{color: t.palette.primary_500}, a.text_md]}> - <Trans>Continue thread...</Trans> - </Text> - </View> - </Link> - ) -} diff --git a/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx b/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx deleted file mode 100644 index 7dc75520b..000000000 --- a/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import {View} from 'react-native' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -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 PostThreadShowHiddenReplies({ - type, - onPress, - hideTopBorder, -}: { - type: 'hidden' | 'muted' - onPress: () => void - hideTopBorder?: boolean -}) { - const {_} = useLingui() - const t = useTheme() - const label = - type === 'muted' ? _(msg`Show muted replies`) : _(msg`Show hidden replies`) - - return ( - <Button onPress={onPress} label={label}> - {({hovered, pressed}) => ( - <View - style={[ - a.flex_1, - a.flex_row, - a.align_center, - a.gap_sm, - a.py_lg, - a.px_xl, - !hideTopBorder && a.border_t, - t.atoms.border_contrast_low, - hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg, - ]}> - <View - style={[ - t.atoms.bg_contrast_25, - a.align_center, - a.justify_center, - { - width: 26, - height: 26, - borderRadius: 13, - marginRight: 4, - }, - ]}> - <EyeSlash size="sm" fill={t.atoms.text_contrast_medium.color} /> - </View> - <Text - style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]} - numberOfLines={1}> - {label} - </Text> - </View> - )} - </Button> - ) -} diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx index d2e492f7e..f2afe8235 100644 --- a/src/view/screens/DebugMod.tsx +++ b/src/view/screens/DebugMod.tsx @@ -31,8 +31,11 @@ import { groupNotifications, shouldFilterNotif, } from '#/state/queries/notifications/util' +import {threadPost} from '#/state/queries/usePostThread/views' import {useSession} from '#/state/session' import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {ThreadItemAnchor} from '#/screens/PostThread/components/ThreadItemAnchor' +import {ThreadItemPost} from '#/screens/PostThread/components/ThreadItemPost' import {ProfileHeaderStandard} from '#/screens/Profile/Header/ProfileHeaderStandard' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -49,7 +52,6 @@ import * as ProfileCard from '#/components/ProfileCard' import {H1, H3, P, Text} from '#/components/Typography' import {ScreenHider} from '../../components/moderation/ScreenHider' import {NotificationFeedItem} from '../com/notifications/NotificationFeedItem' -import {PostThreadItem} from '../com/post-thread/PostThreadItem' import {PostFeedItem} from '../com/posts/PostFeedItem' const LABEL_VALUES: (keyof typeof LABELS)[] = Object.keys( @@ -519,13 +521,13 @@ export const DebugModScreen = ({}: NativeStackScreenProps< <MockPostFeedItem post={post} moderation={postModeration} /> <Heading title="Post" subtitle="viewed directly" /> - <MockPostThreadItem post={post} moderation={postModeration} /> + <MockPostThreadItem post={post} moderationOpts={modOpts} /> <Heading title="Post" subtitle="reply in thread" /> <MockPostThreadItem post={post} - moderation={postModeration} - reply + moderationOpts={modOpts} + isReply /> </> )} @@ -837,28 +839,33 @@ function MockPostFeedItem({ function MockPostThreadItem({ post, - moderation, - reply, + moderationOpts, + isReply, }: { post: AppBskyFeedDefs.PostView - moderation: ModerationDecision - reply?: boolean + moderationOpts: ModerationOpts + isReply?: boolean }) { - return ( - <PostThreadItem - // @ts-ignore - post={post} - record={post.record as AppBskyFeedPost.Record} - moderation={moderation} - depth={reply ? 1 : 0} - isHighlightedPost={!reply} - treeView={false} - prevPost={undefined} - nextPost={undefined} - hasPrecedingItem={false} - overrideBlur={false} - onPostReply={() => {}} - /> + const thread = threadPost({ + uri: post.uri, + depth: isReply ? 1 : 0, + value: { + $type: 'app.bsky.unspecced.defs#threadItemPost', + post, + moreParents: false, + moreReplies: 0, + opThread: false, + hiddenByThreadgate: false, + mutedByViewer: false, + }, + moderationOpts, + threadgateHiddenReplies: new Set<string>(), + }) + + return isReply ? ( + <ThreadItemPost item={thread} /> + ) : ( + <ThreadItemAnchor item={thread} /> ) } diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index cc611e0d6..f07c971fb 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -5,17 +5,14 @@ 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<CommonNavigatorParams, 'PostThread'> 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) @@ -28,11 +25,7 @@ export function PostThreadScreen({route}: Props) { return ( <Layout.Screen testID="postThreadScreen"> - {gate('post_threads_v2_unspecced') || __DEV__ ? ( - <PostThread uri={uri} /> - ) : ( - <PostThreadComponent uri={uri} /> - )} + <PostThread uri={uri} /> </Layout.Screen> ) } |