mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at thread-bug 29 kB view raw
1import React, {memo, useRef, useState} from 'react' 2import {useWindowDimensions, View} from 'react-native' 3import {runOnJS, useAnimatedStyle} from 'react-native-reanimated' 4import Animated from 'react-native-reanimated' 5import { 6 AppBskyFeedDefs, 7 type AppBskyFeedThreadgate, 8 moderatePost, 9} from '@atproto/api' 10import {msg, Trans} from '@lingui/macro' 11import {useLingui} from '@lingui/react' 12 13import {HITSLOP_10} from '#/lib/constants' 14import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 15import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16import {useSetTitle} from '#/lib/hooks/useSetTitle' 17import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 18import {ScrollProvider} from '#/lib/ScrollContext' 19import {sanitizeDisplayName} from '#/lib/strings/display-names' 20import {cleanError} from '#/lib/strings/errors' 21import {isAndroid, isNative, isWeb} from '#/platform/detection' 22import {useFeedFeedback} from '#/state/feed-feedback' 23import {useModerationOpts} from '#/state/preferences/moderation-opts' 24import { 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' 34import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences' 35import {usePreferencesQuery} from '#/state/queries/preferences' 36import {useSession} from '#/state/session' 37import {useShellLayout} from '#/state/shell/shell-layout' 38import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 39import {useUnstablePostSource} from '#/state/unstable-post-source' 40import {List, type ListMethods} from '#/view/com/util/List' 41import {atoms as a, useTheme} from '#/alf' 42import {Button, ButtonIcon} from '#/components/Button' 43import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' 44import {Header} from '#/components/Layout' 45import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 46import * as Menu from '#/components/Menu' 47import {Text} from '#/components/Typography' 48import {PostThreadComposePrompt} from './PostThreadComposePrompt' 49import {PostThreadItem} from './PostThreadItem' 50import {PostThreadLoadMore} from './PostThreadLoadMore' 51import {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*. 55const PARENTS_CHUNK_SIZE = 15 56 57const 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 63const REPLY_PROMPT = {_reactKey: '__reply__'} 64const LOAD_MORE = {_reactKey: '__load_more__'} 65const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'} 66const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'} 67 68enum HiddenRepliesState { 69 Hide, 70 Show, 71 ShowAndOverridePostHider, 72} 73 74type YieldedItem = 75 | ThreadPost 76 | ThreadBlocked 77 | ThreadNotFound 78 | typeof SHOW_HIDDEN_REPLIES 79 | typeof SHOW_MUTED_REPLIES 80type 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 86type ThreadSkeletonParts = { 87 parents: YieldedItem[] 88 highlightedPost: ThreadNode 89 replies: YieldedItem[] 90} 91 92const keyExtractor = (item: RowItem) => { 93 return item._reactKey 94} 95 96export 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 620let 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} 734ThreadMenu = memo(ThreadMenu) 735 736function 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 752function isThreadPost(v: unknown): v is ThreadPost { 753 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' 754} 755 756function isThreadNotFound(v: unknown): v is ThreadNotFound { 757 return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found' 758} 759 760function isThreadBlocked(v: unknown): v is ThreadBlocked { 761 return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked' 762} 763 764function 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 790function* 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 809enum HiddenReplyType { 810 None = 0, 811 Muted = 1, 812 Hidden = 2, 813} 814 815function* 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 892function hasPwiOptOut(node: ThreadPost) { 893 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') 894} 895 896function 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}