Bluesky app fork with some witchin' additions 💫

Threads v2 cleanup (#8902)

* Delete root PostThread component

* Remove PostThreadItem, migrate DebugMod to use new components

* Remove other unused components

* Move PostThreadFollowBtn to new home

* Move PostThreadComposePrompt to new home

* Remove gate

* Keep naming in DebugMod

* rm v1 prefs

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

Eric Bailey
Samuel Newman
and committed by
GitHub
df20ae23 e91a6838

+40 -2272
-1
src/lib/statsig/gates.ts
··· 8 8 | 'old_postonboarding' 9 9 | 'onboarding_add_video_feed' 10 10 | 'post_follow_profile_suggested_accounts' 11 - | 'post_threads_v2_unspecced' 12 11 | 'remove_show_latest_button' 13 12 | 'test_gate_1' 14 13 | 'test_gate_2'
+2 -2
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 32 32 import {type OnPostSuccessData} from '#/state/shell/composer' 33 33 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 34 34 import {type PostSource} from '#/state/unstable-post-source' 35 - import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' 36 35 import {formatCount} from '#/view/com/util/numeric/format' 37 36 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 37 + import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' 38 38 import { 39 39 LINEAR_AVI_WIDTH, 40 40 OUTER_SPACE, ··· 367 367 </Link> 368 368 {showFollowButton && ( 369 369 <View collapsable={false}> 370 - <PostThreadFollowBtn did={post.author.did} /> 370 + <ThreadItemAnchorFollowButton did={post.author.did} /> 371 371 </View> 372 372 )} 373 373 </View>
+3 -3
src/screens/PostThread/index.tsx
··· 12 12 import {type OnPostSuccessData} from '#/state/shell/composer' 13 13 import {useShellLayout} from '#/state/shell/shell-layout' 14 14 import {useUnstablePostSource} from '#/state/unstable-post-source' 15 - import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' 16 15 import {List, type ListMethods} from '#/view/com/util/List' 17 16 import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown' 17 + import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt' 18 18 import {ThreadError} from '#/screens/PostThread/components/ThreadError' 19 19 import { 20 20 ThreadItemAnchor, ··· 455 455 return ( 456 456 <View> 457 457 {gtMobile && ( 458 - <PostThreadComposePrompt onPressCompose={onReplyToAnchor} /> 458 + <ThreadComposePrompt onPressCompose={onReplyToAnchor} /> 459 459 )} 460 460 </View> 461 461 ) ··· 586 586 587 587 return ( 588 588 <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}> 589 - <PostThreadComposePrompt onPressCompose={onPressReply} /> 589 + <ThreadComposePrompt onPressCompose={onPressReply} /> 590 590 </Animated.View> 591 591 ) 592 592 }
-158
src/screens/Settings/ThreadPreferences.tsx
··· 6 6 type CommonNavigatorParams, 7 7 type NativeStackScreenProps, 8 8 } from '#/lib/routes/types' 9 - import {useGate} from '#/lib/statsig/statsig' 10 - import { 11 - usePreferencesQuery, 12 - useSetThreadViewPreferencesMutation, 13 - } from '#/state/queries/preferences' 14 9 import { 15 10 normalizeSort, 16 11 normalizeView, ··· 18 13 } from '#/state/queries/preferences/useThreadPreferences' 19 14 import {atoms as a, useTheme} from '#/alf' 20 15 import * as Toggle from '#/components/forms/Toggle' 21 - import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' 22 16 import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' 23 17 import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 24 18 import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree' ··· 28 22 29 23 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'> 30 24 export function ThreadPreferencesScreen({}: Props) { 31 - const gate = useGate() 32 - 33 - return gate('post_threads_v2_unspecced') ? ( 34 - <ThreadPreferencesV2 /> 35 - ) : ( 36 - <ThreadPreferencesV1 /> 37 - ) 38 - } 39 - 40 - export function ThreadPreferencesV2() { 41 25 const t = useTheme() 42 26 const {_} = useLingui() 43 27 const { ··· 150 134 </Layout.Screen> 151 135 ) 152 136 } 153 - 154 - export function ThreadPreferencesV1() { 155 - const {_} = useLingui() 156 - const t = useTheme() 157 - 158 - const {data: preferences} = usePreferencesQuery() 159 - const {mutate: setThreadViewPrefs, variables} = 160 - useSetThreadViewPreferencesMutation() 161 - 162 - const sortReplies = variables?.sort ?? preferences?.threadViewPrefs?.sort 163 - 164 - const prioritizeFollowedUsers = Boolean( 165 - variables?.prioritizeFollowedUsers ?? 166 - preferences?.threadViewPrefs?.prioritizeFollowedUsers, 167 - ) 168 - const treeViewEnabled = Boolean( 169 - variables?.lab_treeViewEnabled ?? 170 - preferences?.threadViewPrefs?.lab_treeViewEnabled, 171 - ) 172 - 173 - return ( 174 - <Layout.Screen testID="threadPreferencesScreen"> 175 - <Layout.Header.Outer> 176 - <Layout.Header.BackButton /> 177 - <Layout.Header.Content> 178 - <Layout.Header.TitleText> 179 - <Trans>Thread Preferences</Trans> 180 - </Layout.Header.TitleText> 181 - </Layout.Header.Content> 182 - <Layout.Header.Slot /> 183 - </Layout.Header.Outer> 184 - <Layout.Content> 185 - <SettingsList.Container> 186 - <SettingsList.Group> 187 - <SettingsList.ItemIcon icon={BubblesIcon} /> 188 - <SettingsList.ItemText> 189 - <Trans>Sort replies</Trans> 190 - </SettingsList.ItemText> 191 - <View style={[a.w_full, a.gap_md]}> 192 - <Text style={[a.flex_1, t.atoms.text_contrast_medium]}> 193 - <Trans>Sort replies to the same post by:</Trans> 194 - </Text> 195 - <Toggle.Group 196 - label={_(msg`Sort replies by`)} 197 - type="radio" 198 - values={sortReplies ? [sortReplies] : []} 199 - onChange={values => setThreadViewPrefs({sort: values[0]})}> 200 - <View style={[a.gap_sm, a.flex_1]}> 201 - <Toggle.Item name="hotness" label={_(msg`Hot replies first`)}> 202 - <Toggle.Radio /> 203 - <Toggle.LabelText> 204 - <Trans>Hot replies first</Trans> 205 - </Toggle.LabelText> 206 - </Toggle.Item> 207 - <Toggle.Item 208 - name="oldest" 209 - label={_(msg`Oldest replies first`)}> 210 - <Toggle.Radio /> 211 - <Toggle.LabelText> 212 - <Trans>Oldest replies first</Trans> 213 - </Toggle.LabelText> 214 - </Toggle.Item> 215 - <Toggle.Item 216 - name="newest" 217 - label={_(msg`Newest replies first`)}> 218 - <Toggle.Radio /> 219 - <Toggle.LabelText> 220 - <Trans>Newest replies first</Trans> 221 - </Toggle.LabelText> 222 - </Toggle.Item> 223 - <Toggle.Item 224 - name="most-likes" 225 - label={_(msg`Most-liked replies first`)}> 226 - <Toggle.Radio /> 227 - <Toggle.LabelText> 228 - <Trans>Most-liked first</Trans> 229 - </Toggle.LabelText> 230 - </Toggle.Item> 231 - <Toggle.Item 232 - name="random" 233 - label={_(msg`Random (aka "Poster's Roulette")`)}> 234 - <Toggle.Radio /> 235 - <Toggle.LabelText> 236 - <Trans>Random (aka "Poster's Roulette")</Trans> 237 - </Toggle.LabelText> 238 - </Toggle.Item> 239 - </View> 240 - </Toggle.Group> 241 - </View> 242 - </SettingsList.Group> 243 - <SettingsList.Group> 244 - <SettingsList.ItemIcon icon={PersonGroupIcon} /> 245 - <SettingsList.ItemText> 246 - <Trans>Prioritize your Follows</Trans> 247 - </SettingsList.ItemText> 248 - <Toggle.Item 249 - type="checkbox" 250 - name="prioritize-follows" 251 - label={_(msg`Prioritize your Follows`)} 252 - value={prioritizeFollowedUsers} 253 - onChange={value => 254 - setThreadViewPrefs({ 255 - prioritizeFollowedUsers: value, 256 - }) 257 - } 258 - style={[a.w_full, a.gap_md]}> 259 - <Toggle.LabelText style={[a.flex_1]}> 260 - <Trans> 261 - Show replies by people you follow before all other replies 262 - </Trans> 263 - </Toggle.LabelText> 264 - <Toggle.Platform /> 265 - </Toggle.Item> 266 - </SettingsList.Group> 267 - <SettingsList.Divider /> 268 - <SettingsList.Group> 269 - <SettingsList.ItemIcon icon={BeakerIcon} /> 270 - <SettingsList.ItemText> 271 - <Trans>Experimental</Trans> 272 - </SettingsList.ItemText> 273 - <Toggle.Item 274 - type="checkbox" 275 - name="threaded-mode" 276 - label={_(msg`Threaded mode`)} 277 - value={treeViewEnabled} 278 - onChange={value => 279 - setThreadViewPrefs({ 280 - lab_treeViewEnabled: value, 281 - }) 282 - } 283 - style={[a.w_full, a.gap_md]}> 284 - <Toggle.LabelText style={[a.flex_1]}> 285 - <Trans>Show replies as threaded</Trans> 286 - </Toggle.LabelText> 287 - <Toggle.Platform /> 288 - </Toggle.Item> 289 - </SettingsList.Group> 290 - </SettingsList.Container> 291 - </Layout.Content> 292 - </Layout.Screen> 293 - ) 294 - }
+2 -2
src/screens/VideoFeed/index.tsx
··· 80 80 import {useSession} from '#/state/session' 81 81 import {useSetMinimalShellMode} from '#/state/shell' 82 82 import {useSetLightStatusBar} from '#/state/shell/light-status-bar' 83 - import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' 84 83 import {List} from '#/view/com/util/List' 85 84 import {UserAvatar} from '#/view/com/util/UserAvatar' 85 + import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt' 86 86 import {Header} from '#/screens/VideoFeed/components/Header' 87 87 import {atoms as a, ios, platform, ThemeProvider, useTheme} from '#/alf' 88 88 import {setSystemUITheme} from '#/alf/util/systemUI' ··· 883 883 player={player} 884 884 seekingAnimationSV={seekingAnimationSV} 885 885 scrollGesture={scrollGesture}> 886 - <PostThreadComposePrompt 886 + <ThreadComposePrompt 887 887 onPressCompose={onPressReply} 888 888 style={[a.pt_md, a.pb_sm]} 889 889 />
-910
src/view/com/post-thread/PostThread.tsx
··· 1 - import React, {memo, useRef, useState} from 'react' 2 - import {useWindowDimensions, View} from 'react-native' 3 - import {runOnJS, useAnimatedStyle} from 'react-native-reanimated' 4 - import Animated from 'react-native-reanimated' 5 - import { 6 - AppBskyFeedDefs, 7 - type AppBskyFeedThreadgate, 8 - moderatePost, 9 - } from '@atproto/api' 10 - import {msg, Trans} from '@lingui/macro' 11 - import {useLingui} from '@lingui/react' 12 - 13 - import {HITSLOP_10} from '#/lib/constants' 14 - import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 15 - import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16 - import {useSetTitle} from '#/lib/hooks/useSetTitle' 17 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 18 - import {ScrollProvider} from '#/lib/ScrollContext' 19 - import {sanitizeDisplayName} from '#/lib/strings/display-names' 20 - import {cleanError} from '#/lib/strings/errors' 21 - import {isAndroid, isNative, isWeb} from '#/platform/detection' 22 - import {useFeedFeedback} from '#/state/feed-feedback' 23 - import {useModerationOpts} from '#/state/preferences/moderation-opts' 24 - import { 25 - fillThreadModerationCache, 26 - sortThread, 27 - type ThreadBlocked, 28 - type ThreadModerationCache, 29 - type ThreadNode, 30 - type ThreadNotFound, 31 - type ThreadPost, 32 - usePostThreadQuery, 33 - } from '#/state/queries/post-thread' 34 - import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences' 35 - import {usePreferencesQuery} from '#/state/queries/preferences' 36 - import {useSession} from '#/state/session' 37 - import {useShellLayout} from '#/state/shell/shell-layout' 38 - import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 39 - import {useUnstablePostSource} from '#/state/unstable-post-source' 40 - import {List, type ListMethods} from '#/view/com/util/List' 41 - import {atoms as a, useTheme} from '#/alf' 42 - import {Button, ButtonIcon} from '#/components/Button' 43 - import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' 44 - import {Header} from '#/components/Layout' 45 - import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 46 - import * as Menu from '#/components/Menu' 47 - import {Text} from '#/components/Typography' 48 - import {PostThreadComposePrompt} from './PostThreadComposePrompt' 49 - import {PostThreadItem} from './PostThreadItem' 50 - import {PostThreadLoadMore} from './PostThreadLoadMore' 51 - import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies' 52 - 53 - // FlatList maintainVisibleContentPosition breaks if too many items 54 - // are prepended. This seems to be an optimal number based on *shrug*. 55 - const PARENTS_CHUNK_SIZE = 15 56 - 57 - const MAINTAIN_VISIBLE_CONTENT_POSITION = { 58 - // We don't insert any elements before the root row while loading. 59 - // So the row we want to use as the scroll anchor is the first row. 60 - minIndexForVisible: 0, 61 - } 62 - 63 - const REPLY_PROMPT = {_reactKey: '__reply__'} 64 - const LOAD_MORE = {_reactKey: '__load_more__'} 65 - const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'} 66 - const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'} 67 - 68 - enum HiddenRepliesState { 69 - Hide, 70 - Show, 71 - ShowAndOverridePostHider, 72 - } 73 - 74 - type YieldedItem = 75 - | ThreadPost 76 - | ThreadBlocked 77 - | ThreadNotFound 78 - | typeof SHOW_HIDDEN_REPLIES 79 - | typeof SHOW_MUTED_REPLIES 80 - type RowItem = 81 - | YieldedItem 82 - // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape. 83 - | typeof REPLY_PROMPT 84 - | typeof LOAD_MORE 85 - 86 - type ThreadSkeletonParts = { 87 - parents: YieldedItem[] 88 - highlightedPost: ThreadNode 89 - replies: YieldedItem[] 90 - } 91 - 92 - const keyExtractor = (item: RowItem) => { 93 - return item._reactKey 94 - } 95 - 96 - export function PostThread({uri}: {uri: string}) { 97 - const {hasSession, currentAccount} = useSession() 98 - const {_} = useLingui() 99 - const t = useTheme() 100 - const {isMobile} = useWebMediaQueries() 101 - const initialNumToRender = useInitialNumToRender() 102 - const {height: windowHeight} = useWindowDimensions() 103 - const [hiddenRepliesState, setHiddenRepliesState] = React.useState( 104 - HiddenRepliesState.Hide, 105 - ) 106 - const headerRef = React.useRef<View | null>(null) 107 - const anchorPostSource = useUnstablePostSource(uri) 108 - const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) 109 - 110 - const {data: preferences} = usePreferencesQuery() 111 - const { 112 - isFetching, 113 - isError: isThreadError, 114 - error: threadError, 115 - refetch, 116 - data: {thread, threadgate} = {}, 117 - dataUpdatedAt: fetchedAt, 118 - } = usePostThreadQuery(uri) 119 - 120 - // The original source of truth for these are the server settings. 121 - const serverPrefs = preferences?.threadViewPrefs 122 - const serverPrioritizeFollowedUsers = 123 - serverPrefs?.prioritizeFollowedUsers ?? true 124 - const serverTreeViewEnabled = serverPrefs?.lab_treeViewEnabled ?? false 125 - const serverSortReplies = serverPrefs?.sort ?? 'hotness' 126 - 127 - // However, we also need these to work locally for PWI (without persistence). 128 - // So we're mirroring them locally. 129 - const prioritizeFollowedUsers = serverPrioritizeFollowedUsers 130 - const [treeViewEnabled, setTreeViewEnabled] = useState(serverTreeViewEnabled) 131 - const [sortReplies, setSortReplies] = useState(serverSortReplies) 132 - 133 - // We'll reset the local state if new server state flows down to us. 134 - const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs) 135 - if (prevServerPrefs !== serverPrefs) { 136 - setPrevServerPrefs(serverPrefs) 137 - setTreeViewEnabled(serverTreeViewEnabled) 138 - setSortReplies(serverSortReplies) 139 - } 140 - 141 - // And we'll update the local state when mutating the server prefs. 142 - const {mutate: mutateThreadViewPrefs} = useSetThreadViewPreferencesMutation() 143 - function updateTreeViewEnabled(newTreeViewEnabled: boolean) { 144 - setTreeViewEnabled(newTreeViewEnabled) 145 - if (hasSession) { 146 - mutateThreadViewPrefs({lab_treeViewEnabled: newTreeViewEnabled}) 147 - } 148 - } 149 - function updateSortReplies(newSortReplies: string) { 150 - setSortReplies(newSortReplies) 151 - if (hasSession) { 152 - mutateThreadViewPrefs({sort: newSortReplies}) 153 - } 154 - } 155 - 156 - const treeView = React.useMemo( 157 - () => treeViewEnabled && hasBranchingReplies(thread), 158 - [treeViewEnabled, thread], 159 - ) 160 - 161 - const rootPost = thread?.type === 'post' ? thread.post : undefined 162 - const rootPostRecord = thread?.type === 'post' ? thread.record : undefined 163 - const threadgateRecord = threadgate?.record as 164 - | AppBskyFeedThreadgate.Record 165 - | undefined 166 - const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 167 - threadgateRecord, 168 - }) 169 - 170 - const moderationOpts = useModerationOpts() 171 - const isNoPwi = React.useMemo(() => { 172 - const mod = 173 - rootPost && moderationOpts 174 - ? moderatePost(rootPost, moderationOpts) 175 - : undefined 176 - return !!mod 177 - ?.ui('contentList') 178 - .blurs.find( 179 - cause => 180 - cause.type === 'label' && 181 - cause.labelDef.identifier === '!no-unauthenticated', 182 - ) 183 - }, [rootPost, moderationOpts]) 184 - 185 - // Values used for proper rendering of parents 186 - const ref = useRef<ListMethods>(null) 187 - const highlightedPostRef = useRef<View | null>(null) 188 - const [maxParents, setMaxParents] = React.useState( 189 - isWeb ? Infinity : PARENTS_CHUNK_SIZE, 190 - ) 191 - const [maxReplies, setMaxReplies] = React.useState(50) 192 - 193 - useSetTitle( 194 - rootPost && !isNoPwi 195 - ? `${sanitizeDisplayName( 196 - rootPost.author.displayName || `@${rootPost.author.handle}`, 197 - )}: "${rootPostRecord!.text}"` 198 - : '', 199 - ) 200 - 201 - // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed. 202 - // This ensures that the first render contains no parents--even if they are already available in the cache. 203 - // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen. 204 - // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead. 205 - const [deferParents, setDeferParents] = React.useState(isNative) 206 - 207 - const currentDid = currentAccount?.did 208 - const threadModerationCache = React.useMemo(() => { 209 - const cache: ThreadModerationCache = new WeakMap() 210 - if (thread && moderationOpts) { 211 - fillThreadModerationCache(cache, thread, moderationOpts) 212 - } 213 - return cache 214 - }, [thread, moderationOpts]) 215 - 216 - const [justPostedUris, setJustPostedUris] = React.useState( 217 - () => new Set<string>(), 218 - ) 219 - 220 - const [fetchedAtCache] = React.useState(() => new Map<string, number>()) 221 - const [randomCache] = React.useState(() => new Map<string, number>()) 222 - const skeleton = React.useMemo(() => { 223 - if (!thread) return null 224 - return createThreadSkeleton( 225 - sortThread( 226 - thread, 227 - { 228 - // Prefer local state as the source of truth. 229 - sort: sortReplies, 230 - lab_treeViewEnabled: treeViewEnabled, 231 - prioritizeFollowedUsers, 232 - }, 233 - threadModerationCache, 234 - currentDid, 235 - justPostedUris, 236 - threadgateHiddenReplies, 237 - fetchedAtCache, 238 - fetchedAt, 239 - randomCache, 240 - ), 241 - currentDid, 242 - treeView, 243 - threadModerationCache, 244 - hiddenRepliesState !== HiddenRepliesState.Hide, 245 - threadgateHiddenReplies, 246 - ) 247 - }, [ 248 - thread, 249 - prioritizeFollowedUsers, 250 - sortReplies, 251 - treeViewEnabled, 252 - currentDid, 253 - treeView, 254 - threadModerationCache, 255 - hiddenRepliesState, 256 - justPostedUris, 257 - threadgateHiddenReplies, 258 - fetchedAtCache, 259 - fetchedAt, 260 - randomCache, 261 - ]) 262 - 263 - const error = React.useMemo(() => { 264 - if (AppBskyFeedDefs.isNotFoundPost(thread)) { 265 - return { 266 - title: _(msg`Post not found`), 267 - message: _(msg`The post may have been deleted.`), 268 - } 269 - } else if (skeleton?.highlightedPost.type === 'blocked') { 270 - return { 271 - title: _(msg`Post hidden`), 272 - message: _( 273 - msg`You have blocked the author or you have been blocked by the author.`, 274 - ), 275 - } 276 - } else if (threadError?.message.startsWith('Post not found')) { 277 - return { 278 - title: _(msg`Post not found`), 279 - message: _(msg`The post may have been deleted.`), 280 - } 281 - } else if (isThreadError) { 282 - return { 283 - message: threadError ? cleanError(threadError) : undefined, 284 - } 285 - } 286 - 287 - return null 288 - }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) 289 - 290 - // construct content 291 - const posts = React.useMemo(() => { 292 - if (!skeleton) return [] 293 - 294 - const {parents, highlightedPost, replies} = skeleton 295 - let arr: RowItem[] = [] 296 - if (highlightedPost.type === 'post') { 297 - // We want to wait for parents to load before rendering. 298 - // If you add something here, you'll need to update both 299 - // maintainVisibleContentPosition and onContentSizeChange 300 - // to "hold onto" the correct row instead of the first one. 301 - 302 - /* 303 - * This is basically `!!parents.length`, see notes on `isParentLoading` 304 - */ 305 - if (!highlightedPost.ctx.isParentLoading && !deferParents) { 306 - // When progressively revealing parents, rendering a placeholder 307 - // here will cause scrolling jumps. Don't add it unless you test it. 308 - // QT'ing this thread is a great way to test all the scrolling hacks: 309 - // https://bsky.app/profile/samuel.bsky.team/post/3kjqhblh6qk2o 310 - 311 - // Everything is loaded 312 - let startIndex = Math.max(0, parents.length - maxParents) 313 - for (let i = startIndex; i < parents.length; i++) { 314 - arr.push(parents[i]) 315 - } 316 - } 317 - arr.push(highlightedPost) 318 - if (!highlightedPost.post.viewer?.replyDisabled) { 319 - arr.push(REPLY_PROMPT) 320 - } 321 - for (let i = 0; i < replies.length; i++) { 322 - arr.push(replies[i]) 323 - if (i === maxReplies) { 324 - break 325 - } 326 - } 327 - } 328 - return arr 329 - }, [skeleton, deferParents, maxParents, maxReplies]) 330 - 331 - // This is only used on the web to keep the post in view when its parents load. 332 - // On native, we rely on `maintainVisibleContentPosition` instead. 333 - const didAdjustScrollWeb = useRef<boolean>(false) 334 - const onContentSizeChangeWeb = React.useCallback(() => { 335 - // only run once 336 - if (didAdjustScrollWeb.current) { 337 - return 338 - } 339 - // wait for loading to finish 340 - if (thread?.type === 'post' && !!thread.parent) { 341 - // Measure synchronously to avoid a layout jump. 342 - const postNode = highlightedPostRef.current 343 - const headerNode = headerRef.current 344 - if (postNode && headerNode) { 345 - let pageY = (postNode as any as Element).getBoundingClientRect().top 346 - pageY -= (headerNode as any as Element).getBoundingClientRect().height 347 - pageY = Math.max(0, pageY) 348 - ref.current?.scrollToOffset({ 349 - animated: false, 350 - offset: pageY, 351 - }) 352 - } 353 - didAdjustScrollWeb.current = true 354 - } 355 - }, [thread]) 356 - 357 - // On native, we reveal parents in chunks. Although they're all already 358 - // loaded and FlatList already has its own virtualization, unfortunately FlatList 359 - // has a bug that causes the content to jump around if too many items are getting 360 - // prepended at once. It also jumps around if items get prepended during scroll. 361 - // To work around this, we prepend rows after scroll bumps against the top and rests. 362 - const needsBumpMaxParents = React.useRef(false) 363 - const onStartReached = React.useCallback(() => { 364 - if (skeleton?.parents && maxParents < skeleton.parents.length) { 365 - needsBumpMaxParents.current = true 366 - } 367 - }, [maxParents, skeleton?.parents]) 368 - const bumpMaxParentsIfNeeded = React.useCallback(() => { 369 - if (!isNative) { 370 - return 371 - } 372 - if (needsBumpMaxParents.current) { 373 - needsBumpMaxParents.current = false 374 - setMaxParents(n => n + PARENTS_CHUNK_SIZE) 375 - } 376 - }, []) 377 - const onScrollToTop = bumpMaxParentsIfNeeded 378 - const onMomentumEnd = React.useCallback(() => { 379 - 'worklet' 380 - runOnJS(bumpMaxParentsIfNeeded)() 381 - }, [bumpMaxParentsIfNeeded]) 382 - 383 - const onEndReached = React.useCallback(() => { 384 - if (isFetching || posts.length < maxReplies) return 385 - setMaxReplies(prev => prev + 50) 386 - }, [isFetching, maxReplies, posts.length]) 387 - 388 - const onPostReply = React.useCallback( 389 - (postUri: string | undefined) => { 390 - refetch() 391 - if (postUri) { 392 - setJustPostedUris(set => { 393 - const nextSet = new Set(set) 394 - nextSet.add(postUri) 395 - return nextSet 396 - }) 397 - } 398 - }, 399 - [refetch], 400 - ) 401 - 402 - const {openComposer} = useOpenComposer() 403 - const onReplyToAnchor = React.useCallback(() => { 404 - if (thread?.type !== 'post') { 405 - return 406 - } 407 - if (anchorPostSource) { 408 - feedFeedback.sendInteraction({ 409 - item: thread.post.uri, 410 - event: 'app.bsky.feed.defs#interactionReply', 411 - feedContext: anchorPostSource.post.feedContext, 412 - reqId: anchorPostSource.post.reqId, 413 - }) 414 - } 415 - openComposer({ 416 - replyTo: { 417 - uri: thread.post.uri, 418 - cid: thread.post.cid, 419 - text: thread.record.text, 420 - author: thread.post.author, 421 - embed: thread.post.embed, 422 - moderation: threadModerationCache.get(thread), 423 - langs: thread.record.langs, 424 - }, 425 - onPost: onPostReply, 426 - }) 427 - }, [ 428 - openComposer, 429 - thread, 430 - onPostReply, 431 - threadModerationCache, 432 - anchorPostSource, 433 - feedFeedback, 434 - ]) 435 - 436 - const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled 437 - const hasParents = 438 - skeleton?.highlightedPost?.type === 'post' && 439 - (skeleton.highlightedPost.ctx.isParentLoading || 440 - Boolean(skeleton?.parents && skeleton.parents.length > 0)) 441 - 442 - const renderItem = ({item, index}: {item: RowItem; index: number}) => { 443 - if (item === REPLY_PROMPT && hasSession) { 444 - return ( 445 - <View> 446 - {!isMobile && ( 447 - <PostThreadComposePrompt onPressCompose={onReplyToAnchor} /> 448 - )} 449 - </View> 450 - ) 451 - } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { 452 - return ( 453 - <PostThreadShowHiddenReplies 454 - type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'} 455 - onPress={() => 456 - setHiddenRepliesState( 457 - item === SHOW_HIDDEN_REPLIES 458 - ? HiddenRepliesState.Show 459 - : HiddenRepliesState.ShowAndOverridePostHider, 460 - ) 461 - } 462 - hideTopBorder={index === 0} 463 - /> 464 - ) 465 - } else if (isThreadNotFound(item)) { 466 - return ( 467 - <View 468 - style={[ 469 - a.p_lg, 470 - index !== 0 && a.border_t, 471 - t.atoms.border_contrast_low, 472 - t.atoms.bg_contrast_25, 473 - ]}> 474 - <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> 475 - <Trans>Deleted post.</Trans> 476 - </Text> 477 - </View> 478 - ) 479 - } else if (isThreadBlocked(item)) { 480 - return ( 481 - <View 482 - style={[ 483 - a.p_lg, 484 - index !== 0 && a.border_t, 485 - t.atoms.border_contrast_low, 486 - t.atoms.bg_contrast_25, 487 - ]}> 488 - <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> 489 - <Trans>Blocked post.</Trans> 490 - </Text> 491 - </View> 492 - ) 493 - } else if (isThreadPost(item)) { 494 - const prev = isThreadPost(posts[index - 1]) 495 - ? (posts[index - 1] as ThreadPost) 496 - : undefined 497 - const next = isThreadPost(posts[index + 1]) 498 - ? (posts[index + 1] as ThreadPost) 499 - : undefined 500 - const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth 501 - const showParentReplyLine = 502 - (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1 503 - const hasUnrevealedParents = 504 - index === 0 && skeleton?.parents && maxParents < skeleton.parents.length 505 - 506 - if (!treeView && prev && item.ctx.hasMoreSelfThread) { 507 - return <PostThreadLoadMore post={prev.post} /> 508 - } 509 - 510 - return ( 511 - <View 512 - ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} 513 - onLayout={deferParents ? () => setDeferParents(false) : undefined}> 514 - <PostThreadItem 515 - post={item.post} 516 - record={item.record} 517 - threadgateRecord={threadgateRecord ?? undefined} 518 - moderation={threadModerationCache.get(item)} 519 - treeView={treeView} 520 - depth={item.ctx.depth} 521 - prevPost={prev} 522 - nextPost={next} 523 - isHighlightedPost={item.ctx.isHighlightedPost} 524 - hasMore={item.ctx.hasMore} 525 - showChildReplyLine={showChildReplyLine} 526 - showParentReplyLine={showParentReplyLine} 527 - hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents} 528 - overrideBlur={ 529 - hiddenRepliesState === 530 - HiddenRepliesState.ShowAndOverridePostHider && 531 - item.ctx.depth > 0 532 - } 533 - onPostReply={onPostReply} 534 - hideTopBorder={index === 0 && !item.ctx.isParentLoading} 535 - anchorPostSource={anchorPostSource} 536 - /> 537 - </View> 538 - ) 539 - } 540 - return null 541 - } 542 - 543 - if (!thread || !preferences || error) { 544 - return ( 545 - <ListMaybePlaceholder 546 - isLoading={!error} 547 - isError={Boolean(error)} 548 - noEmpty 549 - onRetry={refetch} 550 - errorTitle={error?.title} 551 - errorMessage={error?.message} 552 - /> 553 - ) 554 - } 555 - 556 - return ( 557 - <> 558 - <Header.Outer headerRef={headerRef}> 559 - <Header.BackButton /> 560 - <Header.Content> 561 - <Header.TitleText> 562 - <Trans context="description">Post</Trans> 563 - </Header.TitleText> 564 - </Header.Content> 565 - <Header.Slot> 566 - <ThreadMenu 567 - sortReplies={sortReplies} 568 - treeViewEnabled={treeViewEnabled} 569 - setSortReplies={updateSortReplies} 570 - setTreeViewEnabled={updateTreeViewEnabled} 571 - /> 572 - </Header.Slot> 573 - </Header.Outer> 574 - 575 - <ScrollProvider onMomentumEnd={onMomentumEnd}> 576 - <List 577 - ref={ref} 578 - data={posts} 579 - renderItem={renderItem} 580 - keyExtractor={keyExtractor} 581 - onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} 582 - onStartReached={onStartReached} 583 - onEndReached={onEndReached} 584 - onEndReachedThreshold={2} 585 - onScrollToTop={onScrollToTop} 586 - /** 587 - * @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition 588 - */ 589 - maintainVisibleContentPosition={ 590 - isNative && hasParents 591 - ? MAINTAIN_VISIBLE_CONTENT_POSITION 592 - : undefined 593 - } 594 - desktopFixedHeight 595 - removeClippedSubviews={isAndroid ? false : undefined} 596 - ListFooterComponent={ 597 - <ListFooter 598 - // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on 599 - // initial render 600 - isFetchingNextPage={isFetching} 601 - error={cleanError(threadError)} 602 - onRetry={refetch} 603 - // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to 604 - // work without causing weird jumps on web or glitches on native 605 - height={windowHeight - 200} 606 - /> 607 - } 608 - initialNumToRender={initialNumToRender} 609 - windowSize={11} 610 - sideBorders={false} 611 - /> 612 - </ScrollProvider> 613 - {isMobile && canReply && hasSession && ( 614 - <MobileComposePrompt onPressReply={onReplyToAnchor} /> 615 - )} 616 - </> 617 - ) 618 - } 619 - 620 - let ThreadMenu = ({ 621 - sortReplies, 622 - treeViewEnabled, 623 - setSortReplies, 624 - setTreeViewEnabled, 625 - }: { 626 - sortReplies: string 627 - treeViewEnabled: boolean 628 - setSortReplies: (newValue: string) => void 629 - setTreeViewEnabled: (newValue: boolean) => void 630 - }): React.ReactNode => { 631 - const {_} = useLingui() 632 - return ( 633 - <Menu.Root> 634 - <Menu.Trigger label={_(msg`Thread options`)}> 635 - {({props}) => ( 636 - <Button 637 - label={_(msg`Thread options`)} 638 - size="small" 639 - variant="ghost" 640 - color="secondary" 641 - shape="round" 642 - hitSlop={HITSLOP_10} 643 - {...props}> 644 - <ButtonIcon icon={SettingsSlider} size="md" /> 645 - </Button> 646 - )} 647 - </Menu.Trigger> 648 - <Menu.Outer> 649 - <Menu.LabelText> 650 - <Trans>Show replies as</Trans> 651 - </Menu.LabelText> 652 - <Menu.Group> 653 - <Menu.Item 654 - label={_(msg`Linear`)} 655 - onPress={() => { 656 - setTreeViewEnabled(false) 657 - }}> 658 - <Menu.ItemText> 659 - <Trans>Linear</Trans> 660 - </Menu.ItemText> 661 - <Menu.ItemRadio selected={!treeViewEnabled} /> 662 - </Menu.Item> 663 - <Menu.Item 664 - label={_(msg`Threaded`)} 665 - onPress={() => { 666 - setTreeViewEnabled(true) 667 - }}> 668 - <Menu.ItemText> 669 - <Trans>Threaded</Trans> 670 - </Menu.ItemText> 671 - <Menu.ItemRadio selected={treeViewEnabled} /> 672 - </Menu.Item> 673 - </Menu.Group> 674 - <Menu.Divider /> 675 - <Menu.LabelText> 676 - <Trans>Reply sorting</Trans> 677 - </Menu.LabelText> 678 - <Menu.Group> 679 - <Menu.Item 680 - label={_(msg`Hot replies first`)} 681 - onPress={() => { 682 - setSortReplies('hotness') 683 - }}> 684 - <Menu.ItemText> 685 - <Trans>Hot replies first</Trans> 686 - </Menu.ItemText> 687 - <Menu.ItemRadio selected={sortReplies === 'hotness'} /> 688 - </Menu.Item> 689 - <Menu.Item 690 - label={_(msg`Oldest replies first`)} 691 - onPress={() => { 692 - setSortReplies('oldest') 693 - }}> 694 - <Menu.ItemText> 695 - <Trans>Oldest replies first</Trans> 696 - </Menu.ItemText> 697 - <Menu.ItemRadio selected={sortReplies === 'oldest'} /> 698 - </Menu.Item> 699 - <Menu.Item 700 - label={_(msg`Newest replies first`)} 701 - onPress={() => { 702 - setSortReplies('newest') 703 - }}> 704 - <Menu.ItemText> 705 - <Trans>Newest replies first</Trans> 706 - </Menu.ItemText> 707 - <Menu.ItemRadio selected={sortReplies === 'newest'} /> 708 - </Menu.Item> 709 - <Menu.Item 710 - label={_(msg`Most-liked replies first`)} 711 - onPress={() => { 712 - setSortReplies('most-likes') 713 - }}> 714 - <Menu.ItemText> 715 - <Trans>Most-liked replies first</Trans> 716 - </Menu.ItemText> 717 - <Menu.ItemRadio selected={sortReplies === 'most-likes'} /> 718 - </Menu.Item> 719 - <Menu.Item 720 - label={_(msg`Random (aka "Poster's Roulette")`)} 721 - onPress={() => { 722 - setSortReplies('random') 723 - }}> 724 - <Menu.ItemText> 725 - <Trans>Random (aka "Poster's Roulette")</Trans> 726 - </Menu.ItemText> 727 - <Menu.ItemRadio selected={sortReplies === 'random'} /> 728 - </Menu.Item> 729 - </Menu.Group> 730 - </Menu.Outer> 731 - </Menu.Root> 732 - ) 733 - } 734 - ThreadMenu = memo(ThreadMenu) 735 - 736 - function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { 737 - const {footerHeight} = useShellLayout() 738 - 739 - const animatedStyle = useAnimatedStyle(() => { 740 - return { 741 - bottom: footerHeight.get(), 742 - } 743 - }) 744 - 745 - return ( 746 - <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}> 747 - <PostThreadComposePrompt onPressCompose={onPressReply} /> 748 - </Animated.View> 749 - ) 750 - } 751 - 752 - function isThreadPost(v: unknown): v is ThreadPost { 753 - return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' 754 - } 755 - 756 - function isThreadNotFound(v: unknown): v is ThreadNotFound { 757 - return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found' 758 - } 759 - 760 - function isThreadBlocked(v: unknown): v is ThreadBlocked { 761 - return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked' 762 - } 763 - 764 - function createThreadSkeleton( 765 - node: ThreadNode, 766 - currentDid: string | undefined, 767 - treeView: boolean, 768 - modCache: ThreadModerationCache, 769 - showHiddenReplies: boolean, 770 - threadgateRecordHiddenReplies: Set<string>, 771 - ): ThreadSkeletonParts | null { 772 - if (!node) return null 773 - 774 - return { 775 - parents: Array.from(flattenThreadParents(node, !!currentDid)), 776 - highlightedPost: node, 777 - replies: Array.from( 778 - flattenThreadReplies( 779 - node, 780 - currentDid, 781 - treeView, 782 - modCache, 783 - showHiddenReplies, 784 - threadgateRecordHiddenReplies, 785 - ), 786 - ), 787 - } 788 - } 789 - 790 - function* flattenThreadParents( 791 - node: ThreadNode, 792 - hasSession: boolean, 793 - ): Generator<YieldedItem, void> { 794 - if (node.type === 'post') { 795 - if (node.parent) { 796 - yield* flattenThreadParents(node.parent, hasSession) 797 - } 798 - if (!node.ctx.isHighlightedPost) { 799 - yield node 800 - } 801 - } else if (node.type === 'not-found') { 802 - yield node 803 - } else if (node.type === 'blocked') { 804 - yield node 805 - } 806 - } 807 - 808 - // The enum is ordered to make them easy to merge 809 - enum HiddenReplyType { 810 - None = 0, 811 - Muted = 1, 812 - Hidden = 2, 813 - } 814 - 815 - function* flattenThreadReplies( 816 - node: ThreadNode, 817 - currentDid: string | undefined, 818 - treeView: boolean, 819 - modCache: ThreadModerationCache, 820 - showHiddenReplies: boolean, 821 - threadgateRecordHiddenReplies: Set<string>, 822 - ): Generator<YieldedItem, HiddenReplyType> { 823 - if (node.type === 'post') { 824 - // dont show pwi-opted-out posts to logged out users 825 - if (!currentDid && hasPwiOptOut(node)) { 826 - return HiddenReplyType.None 827 - } 828 - 829 - // handle blurred items 830 - if (node.ctx.depth > 0) { 831 - const modui = modCache.get(node)?.ui('contentList') 832 - if (modui?.blur || modui?.filter) { 833 - if (!showHiddenReplies || node.ctx.depth > 1) { 834 - if ((modui.blurs[0] || modui.filters[0]).type === 'muted') { 835 - return HiddenReplyType.Muted 836 - } 837 - return HiddenReplyType.Hidden 838 - } 839 - } 840 - 841 - if (!showHiddenReplies) { 842 - const hiddenByThreadgate = threadgateRecordHiddenReplies.has( 843 - node.post.uri, 844 - ) 845 - const authorIsViewer = node.post.author.did === currentDid 846 - if (hiddenByThreadgate && !authorIsViewer) { 847 - return HiddenReplyType.Hidden 848 - } 849 - } 850 - } 851 - 852 - if (!node.ctx.isHighlightedPost) { 853 - yield node 854 - } 855 - 856 - if (node.replies?.length) { 857 - let hiddenReplies = HiddenReplyType.None 858 - for (const reply of node.replies) { 859 - let hiddenReply = yield* flattenThreadReplies( 860 - reply, 861 - currentDid, 862 - treeView, 863 - modCache, 864 - showHiddenReplies, 865 - threadgateRecordHiddenReplies, 866 - ) 867 - if (hiddenReply > hiddenReplies) { 868 - hiddenReplies = hiddenReply 869 - } 870 - if (!treeView && !node.ctx.isHighlightedPost) { 871 - break 872 - } 873 - } 874 - 875 - // show control to enable hidden replies 876 - if (node.ctx.depth === 0) { 877 - if (hiddenReplies === HiddenReplyType.Muted) { 878 - yield SHOW_MUTED_REPLIES 879 - } else if (hiddenReplies === HiddenReplyType.Hidden) { 880 - yield SHOW_HIDDEN_REPLIES 881 - } 882 - } 883 - } 884 - } else if (node.type === 'not-found') { 885 - yield node 886 - } else if (node.type === 'blocked') { 887 - yield node 888 - } 889 - return HiddenReplyType.None 890 - } 891 - 892 - function hasPwiOptOut(node: ThreadPost) { 893 - return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') 894 - } 895 - 896 - function hasBranchingReplies(node?: ThreadNode) { 897 - if (!node) { 898 - return false 899 - } 900 - if (node.type !== 'post') { 901 - return false 902 - } 903 - if (!node.replies) { 904 - return false 905 - } 906 - if (node.replies.length === 1) { 907 - return hasBranchingReplies(node.replies[0]) 908 - } 909 - return true 910 - }
+1 -1
src/view/com/post-thread/PostThreadComposePrompt.tsx src/screens/PostThread/components/ThreadComposePrompt.tsx
··· 14 14 import {useInteractionState} from '#/components/hooks/useInteractionState' 15 15 import {Text} from '#/components/Typography' 16 16 17 - export function PostThreadComposePrompt({ 17 + export function ThreadComposePrompt({ 18 18 onPressCompose, 19 19 style, 20 20 }: {
+1 -1
src/view/com/post-thread/PostThreadFollowBtn.tsx src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx
··· 17 17 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 18 18 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 19 19 20 - export function PostThreadFollowBtn({did}: {did: string}) { 20 + export function ThreadItemAnchorFollowButton({did}: {did: string}) { 21 21 const {data: profile, isLoading} = useProfileQuery({did}) 22 22 23 23 // We will never hit this - the profile will always be cached or loaded above
-1036
src/view/com/post-thread/PostThreadItem.tsx
··· 1 - import {memo, useCallback, useMemo, useState} from 'react' 2 - import { 3 - type GestureResponderEvent, 4 - StyleSheet, 5 - Text as RNText, 6 - View, 7 - } from 'react-native' 8 - import { 9 - AppBskyFeedDefs, 10 - AppBskyFeedPost, 11 - type AppBskyFeedThreadgate, 12 - AtUri, 13 - type ModerationDecision, 14 - RichText as RichTextAPI, 15 - } from '@atproto/api' 16 - import {msg, Plural, Trans} from '@lingui/macro' 17 - import {useLingui} from '@lingui/react' 18 - 19 - import {useActorStatus} from '#/lib/actor-status' 20 - import {MAX_POST_LINES} from '#/lib/constants' 21 - import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 22 - import {usePalette} from '#/lib/hooks/usePalette' 23 - import {useTranslate} from '#/lib/hooks/useTranslate' 24 - import {makeProfileLink} from '#/lib/routes/links' 25 - import {sanitizeDisplayName} from '#/lib/strings/display-names' 26 - import {sanitizeHandle} from '#/lib/strings/handles' 27 - import {countLines} from '#/lib/strings/helpers' 28 - import {niceDate} from '#/lib/strings/time' 29 - import {s} from '#/lib/styles' 30 - import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' 31 - import {logger} from '#/logger' 32 - import { 33 - POST_TOMBSTONE, 34 - type Shadow, 35 - usePostShadow, 36 - } from '#/state/cache/post-shadow' 37 - import {useProfileShadow} from '#/state/cache/profile-shadow' 38 - import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 39 - import {useLanguagePrefs} from '#/state/preferences' 40 - import {type ThreadPost} from '#/state/queries/post-thread' 41 - import {useSession} from '#/state/session' 42 - import {type OnPostSuccessData} from '#/state/shell/composer' 43 - import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 44 - import {type PostSource} from '#/state/unstable-post-source' 45 - import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' 46 - import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 47 - import {Link} from '#/view/com/util/Link' 48 - import {formatCount} from '#/view/com/util/numeric/format' 49 - import {PostMeta} from '#/view/com/util/PostMeta' 50 - import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 51 - import {atoms as a, useTheme} from '#/alf' 52 - import {colors} from '#/components/Admonition' 53 - import {Button} from '#/components/Button' 54 - import {useInteractionState} from '#/components/hooks/useInteractionState' 55 - import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' 56 - import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' 57 - import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 58 - import {InlineLinkText} from '#/components/Link' 59 - import {ContentHider} from '#/components/moderation/ContentHider' 60 - import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 61 - import {PostAlerts} from '#/components/moderation/PostAlerts' 62 - import {PostHider} from '#/components/moderation/PostHider' 63 - import {type AppModerationCause} from '#/components/Pills' 64 - import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 65 - import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 66 - import {PostControls} from '#/components/PostControls' 67 - import * as Prompt from '#/components/Prompt' 68 - import {RichText} from '#/components/RichText' 69 - import {SubtleWebHover} from '#/components/SubtleWebHover' 70 - import {Text} from '#/components/Typography' 71 - import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 72 - import {WhoCanReply} from '#/components/WhoCanReply' 73 - import * as bsky from '#/types/bsky' 74 - 75 - export function PostThreadItem({ 76 - post, 77 - record, 78 - moderation, 79 - treeView, 80 - depth, 81 - prevPost, 82 - nextPost, 83 - isHighlightedPost, 84 - hasMore, 85 - showChildReplyLine, 86 - showParentReplyLine, 87 - hasPrecedingItem, 88 - overrideBlur, 89 - onPostReply, 90 - onPostSuccess, 91 - hideTopBorder, 92 - threadgateRecord, 93 - anchorPostSource, 94 - }: { 95 - post: AppBskyFeedDefs.PostView 96 - record: AppBskyFeedPost.Record 97 - moderation: ModerationDecision | undefined 98 - treeView: boolean 99 - depth: number 100 - prevPost: ThreadPost | undefined 101 - nextPost: ThreadPost | undefined 102 - isHighlightedPost?: boolean 103 - hasMore?: boolean 104 - showChildReplyLine?: boolean 105 - showParentReplyLine?: boolean 106 - hasPrecedingItem: boolean 107 - overrideBlur: boolean 108 - onPostReply: (postUri: string | undefined) => void 109 - onPostSuccess?: (data: OnPostSuccessData) => void 110 - hideTopBorder?: boolean 111 - threadgateRecord?: AppBskyFeedThreadgate.Record 112 - anchorPostSource?: PostSource 113 - }) { 114 - const postShadowed = usePostShadow(post) 115 - const richText = useMemo( 116 - () => 117 - new RichTextAPI({ 118 - text: record.text, 119 - facets: record.facets, 120 - }), 121 - [record], 122 - ) 123 - if (postShadowed === POST_TOMBSTONE) { 124 - return <PostThreadItemDeleted hideTopBorder={hideTopBorder} /> 125 - } 126 - if (richText && moderation) { 127 - return ( 128 - <PostThreadItemLoaded 129 - // Safeguard from clobbering per-post state below: 130 - key={postShadowed.uri} 131 - post={postShadowed} 132 - prevPost={prevPost} 133 - nextPost={nextPost} 134 - record={record} 135 - richText={richText} 136 - moderation={moderation} 137 - treeView={treeView} 138 - depth={depth} 139 - isHighlightedPost={isHighlightedPost} 140 - hasMore={hasMore} 141 - showChildReplyLine={showChildReplyLine} 142 - showParentReplyLine={showParentReplyLine} 143 - hasPrecedingItem={hasPrecedingItem} 144 - overrideBlur={overrideBlur} 145 - onPostReply={onPostReply} 146 - onPostSuccess={onPostSuccess} 147 - hideTopBorder={hideTopBorder} 148 - threadgateRecord={threadgateRecord} 149 - anchorPostSource={anchorPostSource} 150 - /> 151 - ) 152 - } 153 - return null 154 - } 155 - 156 - function PostThreadItemDeleted({hideTopBorder}: {hideTopBorder?: boolean}) { 157 - const t = useTheme() 158 - return ( 159 - <View 160 - style={[ 161 - t.atoms.bg, 162 - t.atoms.border_contrast_low, 163 - a.p_xl, 164 - a.pl_lg, 165 - a.flex_row, 166 - a.gap_md, 167 - !hideTopBorder && a.border_t, 168 - ]}> 169 - <TrashIcon style={[t.atoms.text]} /> 170 - <Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}> 171 - <Trans>This post has been deleted.</Trans> 172 - </Text> 173 - </View> 174 - ) 175 - } 176 - 177 - let PostThreadItemLoaded = ({ 178 - post, 179 - record, 180 - richText, 181 - moderation, 182 - treeView, 183 - depth, 184 - prevPost, 185 - nextPost, 186 - isHighlightedPost, 187 - hasMore, 188 - showChildReplyLine, 189 - showParentReplyLine, 190 - hasPrecedingItem, 191 - overrideBlur, 192 - onPostReply, 193 - onPostSuccess, 194 - hideTopBorder, 195 - threadgateRecord, 196 - anchorPostSource, 197 - }: { 198 - post: Shadow<AppBskyFeedDefs.PostView> 199 - record: AppBskyFeedPost.Record 200 - richText: RichTextAPI 201 - moderation: ModerationDecision 202 - treeView: boolean 203 - depth: number 204 - prevPost: ThreadPost | undefined 205 - nextPost: ThreadPost | undefined 206 - isHighlightedPost?: boolean 207 - hasMore?: boolean 208 - showChildReplyLine?: boolean 209 - showParentReplyLine?: boolean 210 - hasPrecedingItem: boolean 211 - overrideBlur: boolean 212 - onPostReply: (postUri: string | undefined) => void 213 - onPostSuccess?: (data: OnPostSuccessData) => void 214 - hideTopBorder?: boolean 215 - threadgateRecord?: AppBskyFeedThreadgate.Record 216 - anchorPostSource?: PostSource 217 - }): React.ReactNode => { 218 - const {currentAccount, hasSession} = useSession() 219 - const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) 220 - 221 - const t = useTheme() 222 - const pal = usePalette('default') 223 - const {_, i18n} = useLingui() 224 - const langPrefs = useLanguagePrefs() 225 - const {openComposer} = useOpenComposer() 226 - const [limitLines, setLimitLines] = useState( 227 - () => countLines(richText?.text) >= MAX_POST_LINES, 228 - ) 229 - const shadowedPostAuthor = useProfileShadow(post.author) 230 - const rootUri = record.reply?.root?.uri || post.uri 231 - const postHref = useMemo(() => { 232 - const urip = new AtUri(post.uri) 233 - return makeProfileLink(post.author, 'post', urip.rkey) 234 - }, [post.uri, post.author]) 235 - const itemTitle = _(msg`Post by ${post.author.handle}`) 236 - const authorHref = makeProfileLink(post.author) 237 - const authorTitle = post.author.handle 238 - const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did 239 - const likesHref = useMemo(() => { 240 - const urip = new AtUri(post.uri) 241 - return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') 242 - }, [post.uri, post.author]) 243 - const likesTitle = _(msg`Likes on this post`) 244 - const repostsHref = useMemo(() => { 245 - const urip = new AtUri(post.uri) 246 - return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') 247 - }, [post.uri, post.author]) 248 - const repostsTitle = _(msg`Reposts of this post`) 249 - const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 250 - threadgateRecord, 251 - }) 252 - const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 253 - const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 254 - const isControlledByViewer = new AtUri(rootUri).host === currentAccount?.did 255 - return isControlledByViewer && isPostHiddenByThreadgate 256 - ? [ 257 - { 258 - type: 'reply-hidden', 259 - source: {type: 'user', did: currentAccount?.did}, 260 - priority: 6, 261 - }, 262 - ] 263 - : [] 264 - }, [post, currentAccount?.did, threadgateHiddenReplies, rootUri]) 265 - const quotesHref = useMemo(() => { 266 - const urip = new AtUri(post.uri) 267 - return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') 268 - }, [post.uri, post.author]) 269 - const quotesTitle = _(msg`Quotes of this post`) 270 - const onlyFollowersCanReply = !!threadgateRecord?.allow?.find( 271 - rule => rule.$type === 'app.bsky.feed.threadgate#followerRule', 272 - ) 273 - const showFollowButton = 274 - currentAccount?.did !== post.author.did && !onlyFollowersCanReply 275 - 276 - const needsTranslation = useMemo( 277 - () => 278 - Boolean( 279 - langPrefs.primaryLanguage && 280 - !isPostInLanguage(post, [langPrefs.primaryLanguage]), 281 - ), 282 - [post, langPrefs.primaryLanguage], 283 - ) 284 - 285 - const onPressReply = () => { 286 - if (anchorPostSource && isHighlightedPost) { 287 - feedFeedback.sendInteraction({ 288 - item: post.uri, 289 - event: 'app.bsky.feed.defs#interactionReply', 290 - feedContext: anchorPostSource.post.feedContext, 291 - reqId: anchorPostSource.post.reqId, 292 - }) 293 - } 294 - openComposer({ 295 - replyTo: { 296 - uri: post.uri, 297 - cid: post.cid, 298 - text: record.text, 299 - author: post.author, 300 - embed: post.embed, 301 - moderation, 302 - langs: record.langs, 303 - }, 304 - onPost: onPostReply, 305 - onPostSuccess: onPostSuccess, 306 - }) 307 - } 308 - 309 - const onOpenAuthor = () => { 310 - if (anchorPostSource) { 311 - feedFeedback.sendInteraction({ 312 - item: post.uri, 313 - event: 'app.bsky.feed.defs#clickthroughAuthor', 314 - feedContext: anchorPostSource.post.feedContext, 315 - reqId: anchorPostSource.post.reqId, 316 - }) 317 - } 318 - } 319 - 320 - const onOpenEmbed = () => { 321 - if (anchorPostSource) { 322 - feedFeedback.sendInteraction({ 323 - item: post.uri, 324 - event: 'app.bsky.feed.defs#clickthroughEmbed', 325 - feedContext: anchorPostSource.post.feedContext, 326 - reqId: anchorPostSource.post.reqId, 327 - }) 328 - } 329 - } 330 - 331 - const onPressShowMore = useCallback(() => { 332 - setLimitLines(false) 333 - }, [setLimitLines]) 334 - 335 - const {isActive: live} = useActorStatus(post.author) 336 - 337 - const reason = anchorPostSource?.post.reason 338 - const viaRepost = useMemo(() => { 339 - if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { 340 - return { 341 - uri: reason.uri, 342 - cid: reason.cid, 343 - } 344 - } 345 - }, [reason]) 346 - 347 - if (!record) { 348 - return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} /> 349 - } 350 - 351 - if (isHighlightedPost) { 352 - return ( 353 - <> 354 - {rootUri !== post.uri && ( 355 - <View 356 - style={[ 357 - a.pl_lg, 358 - a.flex_row, 359 - a.pb_xs, 360 - {height: a.pt_lg.paddingTop}, 361 - ]}> 362 - <View style={{width: 42}}> 363 - <View 364 - style={[ 365 - styles.replyLine, 366 - a.flex_grow, 367 - {backgroundColor: pal.colors.replyLine}, 368 - ]} 369 - /> 370 - </View> 371 - </View> 372 - )} 373 - 374 - <View 375 - testID={`postThreadItem-by-${post.author.handle}`} 376 - style={[ 377 - a.px_lg, 378 - t.atoms.border_contrast_low, 379 - // root post styles 380 - rootUri === post.uri && [a.pt_lg], 381 - ]}> 382 - <View style={[a.flex_row, a.gap_md, a.pb_md]}> 383 - <PreviewableUserAvatar 384 - size={42} 385 - profile={post.author} 386 - moderation={moderation.ui('avatar')} 387 - type={post.author.associated?.labeler ? 'labeler' : 'user'} 388 - live={live} 389 - onBeforePress={onOpenAuthor} 390 - /> 391 - <View style={[a.flex_1]}> 392 - <View style={[a.flex_row, a.align_center]}> 393 - <Link 394 - style={[a.flex_shrink]} 395 - href={authorHref} 396 - title={authorTitle} 397 - onBeforePress={onOpenAuthor}> 398 - <Text 399 - emoji 400 - style={[ 401 - a.text_lg, 402 - a.font_bold, 403 - a.leading_snug, 404 - a.self_start, 405 - ]} 406 - numberOfLines={1}> 407 - {sanitizeDisplayName( 408 - post.author.displayName || 409 - sanitizeHandle(post.author.handle), 410 - moderation.ui('displayName'), 411 - )} 412 - </Text> 413 - </Link> 414 - 415 - <View style={[{paddingLeft: 3, top: -1}]}> 416 - <VerificationCheckButton 417 - profile={shadowedPostAuthor} 418 - size="md" 419 - /> 420 - </View> 421 - </View> 422 - <Link style={s.flex1} href={authorHref} title={authorTitle}> 423 - <Text 424 - emoji 425 - style={[ 426 - a.text_md, 427 - a.leading_snug, 428 - t.atoms.text_contrast_medium, 429 - ]} 430 - numberOfLines={1}> 431 - {sanitizeHandle(post.author.handle, '@')} 432 - </Text> 433 - </Link> 434 - </View> 435 - {showFollowButton && ( 436 - <View> 437 - <PostThreadFollowBtn did={post.author.did} /> 438 - </View> 439 - )} 440 - </View> 441 - <View style={[a.pb_sm]}> 442 - <LabelsOnMyPost post={post} style={[a.pb_sm]} /> 443 - <ContentHider 444 - modui={moderation.ui('contentView')} 445 - ignoreMute 446 - childContainerStyle={[a.pt_sm]}> 447 - <PostAlerts 448 - modui={moderation.ui('contentView')} 449 - size="lg" 450 - includeMute 451 - style={[a.pb_sm]} 452 - additionalCauses={additionalPostAlerts} 453 - /> 454 - {richText?.text ? ( 455 - <RichText 456 - enableTags 457 - selectable 458 - value={richText} 459 - style={[a.flex_1, a.text_xl]} 460 - authorHandle={post.author.handle} 461 - shouldProxyLinks={true} 462 - /> 463 - ) : undefined} 464 - {post.embed && ( 465 - <View style={[a.py_xs]}> 466 - <Embed 467 - embed={post.embed} 468 - moderation={moderation} 469 - viewContext={PostEmbedViewContext.ThreadHighlighted} 470 - onOpen={onOpenEmbed} 471 - /> 472 - </View> 473 - )} 474 - </ContentHider> 475 - <ExpandedPostDetails 476 - post={post} 477 - record={record} 478 - isThreadAuthor={isThreadAuthor} 479 - needsTranslation={needsTranslation} 480 - /> 481 - {post.repostCount !== 0 || 482 - post.likeCount !== 0 || 483 - post.quoteCount !== 0 ? ( 484 - // Show this section unless we're *sure* it has no engagement. 485 - <View 486 - style={[ 487 - a.flex_row, 488 - a.align_center, 489 - a.gap_lg, 490 - a.border_t, 491 - a.border_b, 492 - a.mt_md, 493 - a.py_md, 494 - t.atoms.border_contrast_low, 495 - ]}> 496 - {post.repostCount != null && post.repostCount !== 0 ? ( 497 - <Link href={repostsHref} title={repostsTitle}> 498 - <Text 499 - testID="repostCount-expanded" 500 - style={[a.text_md, t.atoms.text_contrast_medium]}> 501 - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 502 - {formatCount(i18n, post.repostCount)} 503 - </Text>{' '} 504 - <Plural 505 - value={post.repostCount} 506 - one="repost" 507 - other="reposts" 508 - /> 509 - </Text> 510 - </Link> 511 - ) : null} 512 - {post.quoteCount != null && 513 - post.quoteCount !== 0 && 514 - !post.viewer?.embeddingDisabled ? ( 515 - <Link href={quotesHref} title={quotesTitle}> 516 - <Text 517 - testID="quoteCount-expanded" 518 - style={[a.text_md, t.atoms.text_contrast_medium]}> 519 - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 520 - {formatCount(i18n, post.quoteCount)} 521 - </Text>{' '} 522 - <Plural 523 - value={post.quoteCount} 524 - one="quote" 525 - other="quotes" 526 - /> 527 - </Text> 528 - </Link> 529 - ) : null} 530 - {post.likeCount != null && post.likeCount !== 0 ? ( 531 - <Link href={likesHref} title={likesTitle}> 532 - <Text 533 - testID="likeCount-expanded" 534 - style={[a.text_md, t.atoms.text_contrast_medium]}> 535 - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 536 - {formatCount(i18n, post.likeCount)} 537 - </Text>{' '} 538 - <Plural value={post.likeCount} one="like" other="likes" /> 539 - </Text> 540 - </Link> 541 - ) : null} 542 - </View> 543 - ) : null} 544 - <View 545 - style={[ 546 - a.pt_sm, 547 - a.pb_2xs, 548 - { 549 - marginLeft: -5, 550 - }, 551 - ]}> 552 - <FeedFeedbackProvider value={feedFeedback}> 553 - <PostControls 554 - big 555 - post={post} 556 - record={record} 557 - richText={richText} 558 - onPressReply={onPressReply} 559 - onPostReply={onPostReply} 560 - logContext="PostThreadItem" 561 - threadgateRecord={threadgateRecord} 562 - feedContext={anchorPostSource?.post?.feedContext} 563 - reqId={anchorPostSource?.post?.reqId} 564 - viaRepost={viaRepost} 565 - /> 566 - </FeedFeedbackProvider> 567 - </View> 568 - </View> 569 - </View> 570 - </> 571 - ) 572 - } else { 573 - const isThreadedChild = treeView && depth > 0 574 - const isThreadedChildAdjacentTop = 575 - isThreadedChild && prevPost?.ctx.depth === depth && depth !== 1 576 - const isThreadedChildAdjacentBot = 577 - isThreadedChild && nextPost?.ctx.depth === depth 578 - return ( 579 - <PostOuterWrapper 580 - post={post} 581 - depth={depth} 582 - showParentReplyLine={!!showParentReplyLine} 583 - treeView={treeView} 584 - hasPrecedingItem={hasPrecedingItem} 585 - hideTopBorder={hideTopBorder}> 586 - <PostHider 587 - testID={`postThreadItem-by-${post.author.handle}`} 588 - href={postHref} 589 - disabled={overrideBlur} 590 - modui={moderation.ui('contentList')} 591 - iconSize={isThreadedChild ? 24 : 42} 592 - iconStyles={ 593 - isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2} 594 - } 595 - profile={post.author} 596 - interpretFilterAsBlur> 597 - <View 598 - style={{ 599 - flexDirection: 'row', 600 - gap: 10, 601 - paddingLeft: 8, 602 - height: isThreadedChildAdjacentTop ? 8 : 16, 603 - }}> 604 - <View style={{width: 42}}> 605 - {!isThreadedChild && showParentReplyLine && ( 606 - <View 607 - style={[ 608 - styles.replyLine, 609 - { 610 - flexGrow: 1, 611 - backgroundColor: pal.colors.replyLine, 612 - marginBottom: 4, 613 - }, 614 - ]} 615 - /> 616 - )} 617 - </View> 618 - </View> 619 - 620 - <View 621 - style={[ 622 - a.flex_row, 623 - a.px_sm, 624 - a.gap_md, 625 - { 626 - paddingBottom: 627 - showChildReplyLine && !isThreadedChild 628 - ? 0 629 - : isThreadedChildAdjacentBot 630 - ? 4 631 - : 8, 632 - }, 633 - ]}> 634 - {/* If we are in threaded mode, the avatar is rendered in PostMeta */} 635 - {!isThreadedChild && ( 636 - <View> 637 - <PreviewableUserAvatar 638 - size={42} 639 - profile={post.author} 640 - moderation={moderation.ui('avatar')} 641 - type={post.author.associated?.labeler ? 'labeler' : 'user'} 642 - live={live} 643 - /> 644 - 645 - {showChildReplyLine && ( 646 - <View 647 - style={[ 648 - styles.replyLine, 649 - { 650 - flexGrow: 1, 651 - backgroundColor: pal.colors.replyLine, 652 - marginTop: 4, 653 - }, 654 - ]} 655 - /> 656 - )} 657 - </View> 658 - )} 659 - 660 - <View style={[a.flex_1]}> 661 - <PostMeta 662 - author={post.author} 663 - moderation={moderation} 664 - timestamp={post.indexedAt} 665 - postHref={postHref} 666 - showAvatar={isThreadedChild} 667 - avatarSize={24} 668 - style={[a.pb_xs]} 669 - /> 670 - <LabelsOnMyPost post={post} style={[a.pb_xs]} /> 671 - <PostAlerts 672 - modui={moderation.ui('contentList')} 673 - style={[a.pb_2xs]} 674 - additionalCauses={additionalPostAlerts} 675 - /> 676 - {richText?.text ? ( 677 - <View style={[a.pb_2xs, a.pr_sm]}> 678 - <RichText 679 - enableTags 680 - value={richText} 681 - style={[a.flex_1, a.text_md]} 682 - numberOfLines={limitLines ? MAX_POST_LINES : undefined} 683 - authorHandle={post.author.handle} 684 - shouldProxyLinks={true} 685 - /> 686 - {limitLines && ( 687 - <ShowMoreTextButton 688 - style={[a.text_md]} 689 - onPress={onPressShowMore} 690 - /> 691 - )} 692 - </View> 693 - ) : undefined} 694 - {post.embed && ( 695 - <View style={[a.pb_xs]}> 696 - <Embed 697 - embed={post.embed} 698 - moderation={moderation} 699 - viewContext={PostEmbedViewContext.Feed} 700 - /> 701 - </View> 702 - )} 703 - <PostControls 704 - post={post} 705 - record={record} 706 - richText={richText} 707 - onPressReply={onPressReply} 708 - logContext="PostThreadItem" 709 - threadgateRecord={threadgateRecord} 710 - /> 711 - </View> 712 - </View> 713 - {hasMore ? ( 714 - <Link 715 - style={[ 716 - styles.loadMore, 717 - { 718 - paddingLeft: treeView ? 8 : 70, 719 - paddingTop: 0, 720 - paddingBottom: treeView ? 4 : 12, 721 - }, 722 - ]} 723 - href={postHref} 724 - title={itemTitle} 725 - noFeedback> 726 - <Text 727 - style={[t.atoms.text_contrast_medium, a.font_bold, a.text_sm]}> 728 - <Trans>More</Trans> 729 - </Text> 730 - <ChevronRightIcon 731 - size="xs" 732 - style={[t.atoms.text_contrast_medium]} 733 - /> 734 - </Link> 735 - ) : undefined} 736 - </PostHider> 737 - </PostOuterWrapper> 738 - ) 739 - } 740 - } 741 - PostThreadItemLoaded = memo(PostThreadItemLoaded) 742 - 743 - function PostOuterWrapper({ 744 - post, 745 - treeView, 746 - depth, 747 - showParentReplyLine, 748 - hasPrecedingItem, 749 - hideTopBorder, 750 - children, 751 - }: React.PropsWithChildren<{ 752 - post: AppBskyFeedDefs.PostView 753 - treeView: boolean 754 - depth: number 755 - showParentReplyLine: boolean 756 - hasPrecedingItem: boolean 757 - hideTopBorder?: boolean 758 - }>) { 759 - const t = useTheme() 760 - const { 761 - state: hover, 762 - onIn: onHoverIn, 763 - onOut: onHoverOut, 764 - } = useInteractionState() 765 - if (treeView && depth > 0) { 766 - return ( 767 - <View 768 - style={[ 769 - a.flex_row, 770 - a.px_sm, 771 - a.flex_row, 772 - t.atoms.border_contrast_low, 773 - styles.cursor, 774 - depth === 1 && a.border_t, 775 - ]} 776 - onPointerEnter={onHoverIn} 777 - onPointerLeave={onHoverOut}> 778 - {Array.from(Array(depth - 1)).map((_, n: number) => ( 779 - <View 780 - key={`${post.uri}-padding-${n}`} 781 - style={[ 782 - a.ml_sm, 783 - t.atoms.border_contrast_low, 784 - { 785 - borderLeftWidth: 2, 786 - paddingLeft: a.pl_sm.paddingLeft - 2, // minus border 787 - }, 788 - ]} 789 - /> 790 - ))} 791 - <View style={a.flex_1}> 792 - <SubtleWebHover 793 - hover={hover} 794 - style={{ 795 - left: (depth === 1 ? 0 : 2) - a.pl_sm.paddingLeft, 796 - right: -a.pr_sm.paddingRight, 797 - }} 798 - /> 799 - {children} 800 - </View> 801 - </View> 802 - ) 803 - } 804 - return ( 805 - <View 806 - onPointerEnter={onHoverIn} 807 - onPointerLeave={onHoverOut} 808 - style={[ 809 - a.border_t, 810 - a.px_sm, 811 - t.atoms.border_contrast_low, 812 - showParentReplyLine && hasPrecedingItem && styles.noTopBorder, 813 - hideTopBorder && styles.noTopBorder, 814 - styles.cursor, 815 - ]}> 816 - <SubtleWebHover hover={hover} /> 817 - {children} 818 - </View> 819 - ) 820 - } 821 - 822 - function ExpandedPostDetails({ 823 - post, 824 - record, 825 - isThreadAuthor, 826 - needsTranslation, 827 - }: { 828 - post: AppBskyFeedDefs.PostView 829 - record: AppBskyFeedPost.Record 830 - isThreadAuthor: boolean 831 - needsTranslation: boolean 832 - }) { 833 - const t = useTheme() 834 - const pal = usePalette('default') 835 - const {_, i18n} = useLingui() 836 - const translate = useTranslate() 837 - const isRootPost = !('reply' in post.record) 838 - const langPrefs = useLanguagePrefs() 839 - 840 - const onTranslatePress = useCallback( 841 - (e: GestureResponderEvent) => { 842 - e.preventDefault() 843 - translate(record.text || '', langPrefs.primaryLanguage) 844 - 845 - if ( 846 - bsky.dangerousIsType<AppBskyFeedPost.Record>( 847 - post.record, 848 - AppBskyFeedPost.isRecord, 849 - ) 850 - ) { 851 - logger.metric( 852 - 'translate', 853 - { 854 - sourceLanguages: post.record.langs ?? [], 855 - targetLanguage: langPrefs.primaryLanguage, 856 - textLength: post.record.text.length, 857 - }, 858 - {statsig: false}, 859 - ) 860 - } 861 - 862 - return false 863 - }, 864 - [translate, record.text, langPrefs, post], 865 - ) 866 - 867 - return ( 868 - <View style={[a.gap_md, a.pt_md, a.align_start]}> 869 - <BackdatedPostIndicator post={post} /> 870 - <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> 871 - <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 872 - {niceDate(i18n, post.indexedAt)} 873 - </Text> 874 - {isRootPost && ( 875 - <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> 876 - )} 877 - {needsTranslation && ( 878 - <> 879 - <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 880 - &middot; 881 - </Text> 882 - 883 - <InlineLinkText 884 - // overridden to open an intent on android, but keep 885 - // as anchor tag for accessibility 886 - to={getTranslatorLink(record.text, langPrefs.primaryLanguage)} 887 - label={_(msg`Translate`)} 888 - style={[a.text_sm, pal.link]} 889 - onPress={onTranslatePress}> 890 - <Trans>Translate</Trans> 891 - </InlineLinkText> 892 - </> 893 - )} 894 - </View> 895 - </View> 896 - ) 897 - } 898 - 899 - function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { 900 - const t = useTheme() 901 - const {_, i18n} = useLingui() 902 - const control = Prompt.usePromptControl() 903 - 904 - const indexedAt = new Date(post.indexedAt) 905 - const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>( 906 - post.record, 907 - AppBskyFeedPost.isRecord, 908 - ) 909 - ? new Date(post.record.createdAt) 910 - : new Date(post.indexedAt) 911 - 912 - // backdated if createdAt is 24 hours or more before indexedAt 913 - const isBackdated = 914 - indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000 915 - 916 - if (!isBackdated) return null 917 - 918 - const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light 919 - 920 - return ( 921 - <> 922 - <Button 923 - label={_(msg`Archived post`)} 924 - accessibilityHint={_( 925 - msg`Shows information about when this post was created`, 926 - )} 927 - onPress={e => { 928 - e.preventDefault() 929 - e.stopPropagation() 930 - control.open() 931 - }}> 932 - {({hovered, pressed}) => ( 933 - <View 934 - style={[ 935 - a.flex_row, 936 - a.align_center, 937 - a.rounded_full, 938 - t.atoms.bg_contrast_25, 939 - (hovered || pressed) && t.atoms.bg_contrast_50, 940 - { 941 - gap: 3, 942 - paddingHorizontal: 6, 943 - paddingVertical: 3, 944 - }, 945 - ]}> 946 - <CalendarClockIcon fill={orange} size="sm" aria-hidden /> 947 - <Text 948 - style={[ 949 - a.text_xs, 950 - a.font_bold, 951 - a.leading_tight, 952 - t.atoms.text_contrast_medium, 953 - ]}> 954 - <Trans>Archived from {niceDate(i18n, createdAt)}</Trans> 955 - </Text> 956 - </View> 957 - )} 958 - </Button> 959 - 960 - <Prompt.Outer control={control}> 961 - <Prompt.TitleText> 962 - <Trans>Archived post</Trans> 963 - </Prompt.TitleText> 964 - <Prompt.DescriptionText> 965 - <Trans> 966 - This post claims to have been created on{' '} 967 - <RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>, 968 - but was first seen by Bluesky on{' '} 969 - <RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>. 970 - </Trans> 971 - </Prompt.DescriptionText> 972 - <Text 973 - style={[ 974 - a.text_md, 975 - a.leading_snug, 976 - t.atoms.text_contrast_high, 977 - a.pb_xl, 978 - ]}> 979 - <Trans> 980 - Bluesky cannot confirm the authenticity of the claimed date. 981 - </Trans> 982 - </Text> 983 - <Prompt.Actions> 984 - <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} /> 985 - </Prompt.Actions> 986 - </Prompt.Outer> 987 - </> 988 - ) 989 - } 990 - 991 - function getThreadAuthor( 992 - post: AppBskyFeedDefs.PostView, 993 - record: AppBskyFeedPost.Record, 994 - ): string { 995 - if (!record.reply) { 996 - return post.author.did 997 - } 998 - try { 999 - return new AtUri(record.reply.root.uri).host 1000 - } catch { 1001 - return '' 1002 - } 1003 - } 1004 - 1005 - const styles = StyleSheet.create({ 1006 - outer: { 1007 - borderTopWidth: StyleSheet.hairlineWidth, 1008 - paddingLeft: 8, 1009 - }, 1010 - noTopBorder: { 1011 - borderTopWidth: 0, 1012 - }, 1013 - meta: { 1014 - flexDirection: 'row', 1015 - paddingVertical: 2, 1016 - }, 1017 - metaExpandedLine1: { 1018 - paddingVertical: 0, 1019 - }, 1020 - loadMore: { 1021 - flexDirection: 'row', 1022 - alignItems: 'center', 1023 - justifyContent: 'flex-start', 1024 - gap: 4, 1025 - paddingHorizontal: 20, 1026 - }, 1027 - replyLine: { 1028 - width: 2, 1029 - marginLeft: 'auto', 1030 - marginRight: 'auto', 1031 - }, 1032 - cursor: { 1033 - // @ts-ignore web only 1034 - cursor: 'pointer', 1035 - }, 1036 - })
-65
src/view/com/post-thread/PostThreadLoadMore.tsx
··· 1 - import * as React from 'react' 2 - import {View} from 'react-native' 3 - import {AppBskyFeedDefs, AtUri} from '@atproto/api' 4 - import {Trans} from '@lingui/macro' 5 - 6 - import {makeProfileLink} from '#/lib/routes/links' 7 - import {atoms as a, useTheme} from '#/alf' 8 - import {Text} from '#/components/Typography' 9 - import {Link} from '../util/Link' 10 - import {UserAvatar} from '../util/UserAvatar' 11 - 12 - export function PostThreadLoadMore({post}: {post: AppBskyFeedDefs.PostView}) { 13 - const t = useTheme() 14 - 15 - const postHref = React.useMemo(() => { 16 - const urip = new AtUri(post.uri) 17 - return makeProfileLink(post.author, 'post', urip.rkey) 18 - }, [post.uri, post.author]) 19 - 20 - return ( 21 - <Link 22 - href={postHref} 23 - style={[a.flex_row, a.align_center, a.py_md, {paddingHorizontal: 14}]} 24 - hoverStyle={[t.atoms.bg_contrast_25]}> 25 - <View style={[a.flex_row]}> 26 - <View 27 - style={{ 28 - alignItems: 'center', 29 - justifyContent: 'center', 30 - width: 34, 31 - height: 34, 32 - borderRadius: 18, 33 - backgroundColor: t.atoms.bg.backgroundColor, 34 - marginRight: -20, 35 - }}> 36 - <UserAvatar 37 - avatar={post.author.avatar} 38 - size={30} 39 - type={post.author.associated?.labeler ? 'labeler' : 'user'} 40 - /> 41 - </View> 42 - <View 43 - style={{ 44 - alignItems: 'center', 45 - justifyContent: 'center', 46 - width: 34, 47 - height: 34, 48 - borderRadius: 18, 49 - backgroundColor: t.atoms.bg.backgroundColor, 50 - }}> 51 - <UserAvatar 52 - avatar={post.author.avatar} 53 - size={30} 54 - type={post.author.associated?.labeler ? 'labeler' : 'user'} 55 - /> 56 - </View> 57 - </View> 58 - <View style={[a.px_sm]}> 59 - <Text style={[{color: t.palette.primary_500}, a.text_md]}> 60 - <Trans>Continue thread...</Trans> 61 - </Text> 62 - </View> 63 - </Link> 64 - ) 65 - }
-62
src/view/com/post-thread/PostThreadShowHiddenReplies.tsx
··· 1 - import {View} from 'react-native' 2 - import {msg} from '@lingui/macro' 3 - import {useLingui} from '@lingui/react' 4 - 5 - import {atoms as a, useTheme} from '#/alf' 6 - import {Button} from '#/components/Button' 7 - import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 8 - import {Text} from '#/components/Typography' 9 - 10 - export function PostThreadShowHiddenReplies({ 11 - type, 12 - onPress, 13 - hideTopBorder, 14 - }: { 15 - type: 'hidden' | 'muted' 16 - onPress: () => void 17 - hideTopBorder?: boolean 18 - }) { 19 - const {_} = useLingui() 20 - const t = useTheme() 21 - const label = 22 - type === 'muted' ? _(msg`Show muted replies`) : _(msg`Show hidden replies`) 23 - 24 - return ( 25 - <Button onPress={onPress} label={label}> 26 - {({hovered, pressed}) => ( 27 - <View 28 - style={[ 29 - a.flex_1, 30 - a.flex_row, 31 - a.align_center, 32 - a.gap_sm, 33 - a.py_lg, 34 - a.px_xl, 35 - !hideTopBorder && a.border_t, 36 - t.atoms.border_contrast_low, 37 - hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg, 38 - ]}> 39 - <View 40 - style={[ 41 - t.atoms.bg_contrast_25, 42 - a.align_center, 43 - a.justify_center, 44 - { 45 - width: 26, 46 - height: 26, 47 - borderRadius: 13, 48 - marginRight: 4, 49 - }, 50 - ]}> 51 - <EyeSlash size="sm" fill={t.atoms.text_contrast_medium.color} /> 52 - </View> 53 - <Text 54 - style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]} 55 - numberOfLines={1}> 56 - {label} 57 - </Text> 58 - </View> 59 - )} 60 - </Button> 61 - ) 62 - }
+30 -23
src/view/screens/DebugMod.tsx
··· 31 31 groupNotifications, 32 32 shouldFilterNotif, 33 33 } from '#/state/queries/notifications/util' 34 + import {threadPost} from '#/state/queries/usePostThread/views' 34 35 import {useSession} from '#/state/session' 35 36 import {CenteredView, ScrollView} from '#/view/com/util/Views' 37 + import {ThreadItemAnchor} from '#/screens/PostThread/components/ThreadItemAnchor' 38 + import {ThreadItemPost} from '#/screens/PostThread/components/ThreadItemPost' 36 39 import {ProfileHeaderStandard} from '#/screens/Profile/Header/ProfileHeaderStandard' 37 40 import {atoms as a, useTheme} from '#/alf' 38 41 import {Button, ButtonIcon, ButtonText} from '#/components/Button' ··· 49 52 import {H1, H3, P, Text} from '#/components/Typography' 50 53 import {ScreenHider} from '../../components/moderation/ScreenHider' 51 54 import {NotificationFeedItem} from '../com/notifications/NotificationFeedItem' 52 - import {PostThreadItem} from '../com/post-thread/PostThreadItem' 53 55 import {PostFeedItem} from '../com/posts/PostFeedItem' 54 56 55 57 const LABEL_VALUES: (keyof typeof LABELS)[] = Object.keys( ··· 519 521 <MockPostFeedItem post={post} moderation={postModeration} /> 520 522 521 523 <Heading title="Post" subtitle="viewed directly" /> 522 - <MockPostThreadItem post={post} moderation={postModeration} /> 524 + <MockPostThreadItem post={post} moderationOpts={modOpts} /> 523 525 524 526 <Heading title="Post" subtitle="reply in thread" /> 525 527 <MockPostThreadItem 526 528 post={post} 527 - moderation={postModeration} 528 - reply 529 + moderationOpts={modOpts} 530 + isReply 529 531 /> 530 532 </> 531 533 )} ··· 837 839 838 840 function MockPostThreadItem({ 839 841 post, 840 - moderation, 841 - reply, 842 + moderationOpts, 843 + isReply, 842 844 }: { 843 845 post: AppBskyFeedDefs.PostView 844 - moderation: ModerationDecision 845 - reply?: boolean 846 + moderationOpts: ModerationOpts 847 + isReply?: boolean 846 848 }) { 847 - return ( 848 - <PostThreadItem 849 - // @ts-ignore 850 - post={post} 851 - record={post.record as AppBskyFeedPost.Record} 852 - moderation={moderation} 853 - depth={reply ? 1 : 0} 854 - isHighlightedPost={!reply} 855 - treeView={false} 856 - prevPost={undefined} 857 - nextPost={undefined} 858 - hasPrecedingItem={false} 859 - overrideBlur={false} 860 - onPostReply={() => {}} 861 - /> 849 + const thread = threadPost({ 850 + uri: post.uri, 851 + depth: isReply ? 1 : 0, 852 + value: { 853 + $type: 'app.bsky.unspecced.defs#threadItemPost', 854 + post, 855 + moreParents: false, 856 + moreReplies: 0, 857 + opThread: false, 858 + hiddenByThreadgate: false, 859 + mutedByViewer: false, 860 + }, 861 + moderationOpts, 862 + threadgateHiddenReplies: new Set<string>(), 863 + }) 864 + 865 + return isReply ? ( 866 + <ThreadItemPost item={thread} /> 867 + ) : ( 868 + <ThreadItemAnchor item={thread} /> 862 869 ) 863 870 } 864 871
+1 -8
src/view/screens/PostThread.tsx
··· 5 5 type CommonNavigatorParams, 6 6 type NativeStackScreenProps, 7 7 } from '#/lib/routes/types' 8 - import {useGate} from '#/lib/statsig/statsig' 9 8 import {makeRecordUri} from '#/lib/strings/url-helpers' 10 9 import {useSetMinimalShellMode} from '#/state/shell' 11 - import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread' 12 10 import {PostThread} from '#/screens/PostThread' 13 11 import * as Layout from '#/components/Layout' 14 12 15 13 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'> 16 14 export function PostThreadScreen({route}: Props) { 17 15 const setMinimalShellMode = useSetMinimalShellMode() 18 - const gate = useGate() 19 16 20 17 const {name, rkey} = route.params 21 18 const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) ··· 28 25 29 26 return ( 30 27 <Layout.Screen testID="postThreadScreen"> 31 - {gate('post_threads_v2_unspecced') || __DEV__ ? ( 32 - <PostThread uri={uri} /> 33 - ) : ( 34 - <PostThreadComponent uri={uri} /> 35 - )} 28 + <PostThread uri={uri} /> 36 29 </Layout.Screen> 37 30 ) 38 31 }