mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at tooltip 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 }, 424 onPost: onPostReply, 425 }) 426 }, [ 427 openComposer, 428 thread, 429 onPostReply, 430 threadModerationCache, 431 anchorPostSource, 432 feedFeedback, 433 ]) 434 435 const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled 436 const hasParents = 437 skeleton?.highlightedPost?.type === 'post' && 438 (skeleton.highlightedPost.ctx.isParentLoading || 439 Boolean(skeleton?.parents && skeleton.parents.length > 0)) 440 441 const renderItem = ({item, index}: {item: RowItem; index: number}) => { 442 if (item === REPLY_PROMPT && hasSession) { 443 return ( 444 <View> 445 {!isMobile && ( 446 <PostThreadComposePrompt onPressCompose={onReplyToAnchor} /> 447 )} 448 </View> 449 ) 450 } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { 451 return ( 452 <PostThreadShowHiddenReplies 453 type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'} 454 onPress={() => 455 setHiddenRepliesState( 456 item === SHOW_HIDDEN_REPLIES 457 ? HiddenRepliesState.Show 458 : HiddenRepliesState.ShowAndOverridePostHider, 459 ) 460 } 461 hideTopBorder={index === 0} 462 /> 463 ) 464 } else if (isThreadNotFound(item)) { 465 return ( 466 <View 467 style={[ 468 a.p_lg, 469 index !== 0 && a.border_t, 470 t.atoms.border_contrast_low, 471 t.atoms.bg_contrast_25, 472 ]}> 473 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> 474 <Trans>Deleted post.</Trans> 475 </Text> 476 </View> 477 ) 478 } else if (isThreadBlocked(item)) { 479 return ( 480 <View 481 style={[ 482 a.p_lg, 483 index !== 0 && a.border_t, 484 t.atoms.border_contrast_low, 485 t.atoms.bg_contrast_25, 486 ]}> 487 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> 488 <Trans>Blocked post.</Trans> 489 </Text> 490 </View> 491 ) 492 } else if (isThreadPost(item)) { 493 const prev = isThreadPost(posts[index - 1]) 494 ? (posts[index - 1] as ThreadPost) 495 : undefined 496 const next = isThreadPost(posts[index + 1]) 497 ? (posts[index + 1] as ThreadPost) 498 : undefined 499 const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth 500 const showParentReplyLine = 501 (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1 502 const hasUnrevealedParents = 503 index === 0 && skeleton?.parents && maxParents < skeleton.parents.length 504 505 if (!treeView && prev && item.ctx.hasMoreSelfThread) { 506 return <PostThreadLoadMore post={prev.post} /> 507 } 508 509 return ( 510 <View 511 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} 512 onLayout={deferParents ? () => setDeferParents(false) : undefined}> 513 <PostThreadItem 514 post={item.post} 515 record={item.record} 516 threadgateRecord={threadgateRecord ?? undefined} 517 moderation={threadModerationCache.get(item)} 518 treeView={treeView} 519 depth={item.ctx.depth} 520 prevPost={prev} 521 nextPost={next} 522 isHighlightedPost={item.ctx.isHighlightedPost} 523 hasMore={item.ctx.hasMore} 524 showChildReplyLine={showChildReplyLine} 525 showParentReplyLine={showParentReplyLine} 526 hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents} 527 overrideBlur={ 528 hiddenRepliesState === 529 HiddenRepliesState.ShowAndOverridePostHider && 530 item.ctx.depth > 0 531 } 532 onPostReply={onPostReply} 533 hideTopBorder={index === 0 && !item.ctx.isParentLoading} 534 anchorPostSource={anchorPostSource} 535 /> 536 </View> 537 ) 538 } 539 return null 540 } 541 542 if (!thread || !preferences || error) { 543 return ( 544 <ListMaybePlaceholder 545 isLoading={!error} 546 isError={Boolean(error)} 547 noEmpty 548 onRetry={refetch} 549 errorTitle={error?.title} 550 errorMessage={error?.message} 551 /> 552 ) 553 } 554 555 return ( 556 <> 557 <Header.Outer headerRef={headerRef}> 558 <Header.BackButton /> 559 <Header.Content> 560 <Header.TitleText> 561 <Trans context="description">Post</Trans> 562 </Header.TitleText> 563 </Header.Content> 564 <Header.Slot> 565 <ThreadMenu 566 sortReplies={sortReplies} 567 treeViewEnabled={treeViewEnabled} 568 setSortReplies={updateSortReplies} 569 setTreeViewEnabled={updateTreeViewEnabled} 570 /> 571 </Header.Slot> 572 </Header.Outer> 573 574 <ScrollProvider onMomentumEnd={onMomentumEnd}> 575 <List 576 ref={ref} 577 data={posts} 578 renderItem={renderItem} 579 keyExtractor={keyExtractor} 580 onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} 581 onStartReached={onStartReached} 582 onEndReached={onEndReached} 583 onEndReachedThreshold={2} 584 onScrollToTop={onScrollToTop} 585 /** 586 * @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition 587 */ 588 maintainVisibleContentPosition={ 589 isNative && hasParents 590 ? MAINTAIN_VISIBLE_CONTENT_POSITION 591 : undefined 592 } 593 desktopFixedHeight 594 removeClippedSubviews={isAndroid ? false : undefined} 595 ListFooterComponent={ 596 <ListFooter 597 // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on 598 // initial render 599 isFetchingNextPage={isFetching} 600 error={cleanError(threadError)} 601 onRetry={refetch} 602 // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to 603 // work without causing weird jumps on web or glitches on native 604 height={windowHeight - 200} 605 /> 606 } 607 initialNumToRender={initialNumToRender} 608 windowSize={11} 609 sideBorders={false} 610 /> 611 </ScrollProvider> 612 {isMobile && canReply && hasSession && ( 613 <MobileComposePrompt onPressReply={onReplyToAnchor} /> 614 )} 615 </> 616 ) 617} 618 619let ThreadMenu = ({ 620 sortReplies, 621 treeViewEnabled, 622 setSortReplies, 623 setTreeViewEnabled, 624}: { 625 sortReplies: string 626 treeViewEnabled: boolean 627 setSortReplies: (newValue: string) => void 628 setTreeViewEnabled: (newValue: boolean) => void 629}): React.ReactNode => { 630 const {_} = useLingui() 631 return ( 632 <Menu.Root> 633 <Menu.Trigger label={_(msg`Thread options`)}> 634 {({props}) => ( 635 <Button 636 label={_(msg`Thread options`)} 637 size="small" 638 variant="ghost" 639 color="secondary" 640 shape="round" 641 hitSlop={HITSLOP_10} 642 {...props}> 643 <ButtonIcon icon={SettingsSlider} size="md" /> 644 </Button> 645 )} 646 </Menu.Trigger> 647 <Menu.Outer> 648 <Menu.LabelText> 649 <Trans>Show replies as</Trans> 650 </Menu.LabelText> 651 <Menu.Group> 652 <Menu.Item 653 label={_(msg`Linear`)} 654 onPress={() => { 655 setTreeViewEnabled(false) 656 }}> 657 <Menu.ItemText> 658 <Trans>Linear</Trans> 659 </Menu.ItemText> 660 <Menu.ItemRadio selected={!treeViewEnabled} /> 661 </Menu.Item> 662 <Menu.Item 663 label={_(msg`Threaded`)} 664 onPress={() => { 665 setTreeViewEnabled(true) 666 }}> 667 <Menu.ItemText> 668 <Trans>Threaded</Trans> 669 </Menu.ItemText> 670 <Menu.ItemRadio selected={treeViewEnabled} /> 671 </Menu.Item> 672 </Menu.Group> 673 <Menu.Divider /> 674 <Menu.LabelText> 675 <Trans>Reply sorting</Trans> 676 </Menu.LabelText> 677 <Menu.Group> 678 <Menu.Item 679 label={_(msg`Hot replies first`)} 680 onPress={() => { 681 setSortReplies('hotness') 682 }}> 683 <Menu.ItemText> 684 <Trans>Hot replies first</Trans> 685 </Menu.ItemText> 686 <Menu.ItemRadio selected={sortReplies === 'hotness'} /> 687 </Menu.Item> 688 <Menu.Item 689 label={_(msg`Oldest replies first`)} 690 onPress={() => { 691 setSortReplies('oldest') 692 }}> 693 <Menu.ItemText> 694 <Trans>Oldest replies first</Trans> 695 </Menu.ItemText> 696 <Menu.ItemRadio selected={sortReplies === 'oldest'} /> 697 </Menu.Item> 698 <Menu.Item 699 label={_(msg`Newest replies first`)} 700 onPress={() => { 701 setSortReplies('newest') 702 }}> 703 <Menu.ItemText> 704 <Trans>Newest replies first</Trans> 705 </Menu.ItemText> 706 <Menu.ItemRadio selected={sortReplies === 'newest'} /> 707 </Menu.Item> 708 <Menu.Item 709 label={_(msg`Most-liked replies first`)} 710 onPress={() => { 711 setSortReplies('most-likes') 712 }}> 713 <Menu.ItemText> 714 <Trans>Most-liked replies first</Trans> 715 </Menu.ItemText> 716 <Menu.ItemRadio selected={sortReplies === 'most-likes'} /> 717 </Menu.Item> 718 <Menu.Item 719 label={_(msg`Random (aka "Poster's Roulette")`)} 720 onPress={() => { 721 setSortReplies('random') 722 }}> 723 <Menu.ItemText> 724 <Trans>Random (aka "Poster's Roulette")</Trans> 725 </Menu.ItemText> 726 <Menu.ItemRadio selected={sortReplies === 'random'} /> 727 </Menu.Item> 728 </Menu.Group> 729 </Menu.Outer> 730 </Menu.Root> 731 ) 732} 733ThreadMenu = memo(ThreadMenu) 734 735function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { 736 const {footerHeight} = useShellLayout() 737 738 const animatedStyle = useAnimatedStyle(() => { 739 return { 740 bottom: footerHeight.get(), 741 } 742 }) 743 744 return ( 745 <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}> 746 <PostThreadComposePrompt onPressCompose={onPressReply} /> 747 </Animated.View> 748 ) 749} 750 751function isThreadPost(v: unknown): v is ThreadPost { 752 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' 753} 754 755function isThreadNotFound(v: unknown): v is ThreadNotFound { 756 return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found' 757} 758 759function isThreadBlocked(v: unknown): v is ThreadBlocked { 760 return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked' 761} 762 763function createThreadSkeleton( 764 node: ThreadNode, 765 currentDid: string | undefined, 766 treeView: boolean, 767 modCache: ThreadModerationCache, 768 showHiddenReplies: boolean, 769 threadgateRecordHiddenReplies: Set<string>, 770): ThreadSkeletonParts | null { 771 if (!node) return null 772 773 return { 774 parents: Array.from(flattenThreadParents(node, !!currentDid)), 775 highlightedPost: node, 776 replies: Array.from( 777 flattenThreadReplies( 778 node, 779 currentDid, 780 treeView, 781 modCache, 782 showHiddenReplies, 783 threadgateRecordHiddenReplies, 784 ), 785 ), 786 } 787} 788 789function* flattenThreadParents( 790 node: ThreadNode, 791 hasSession: boolean, 792): Generator<YieldedItem, void> { 793 if (node.type === 'post') { 794 if (node.parent) { 795 yield* flattenThreadParents(node.parent, hasSession) 796 } 797 if (!node.ctx.isHighlightedPost) { 798 yield node 799 } 800 } else if (node.type === 'not-found') { 801 yield node 802 } else if (node.type === 'blocked') { 803 yield node 804 } 805} 806 807// The enum is ordered to make them easy to merge 808enum HiddenReplyType { 809 None = 0, 810 Muted = 1, 811 Hidden = 2, 812} 813 814function* flattenThreadReplies( 815 node: ThreadNode, 816 currentDid: string | undefined, 817 treeView: boolean, 818 modCache: ThreadModerationCache, 819 showHiddenReplies: boolean, 820 threadgateRecordHiddenReplies: Set<string>, 821): Generator<YieldedItem, HiddenReplyType> { 822 if (node.type === 'post') { 823 // dont show pwi-opted-out posts to logged out users 824 if (!currentDid && hasPwiOptOut(node)) { 825 return HiddenReplyType.None 826 } 827 828 // handle blurred items 829 if (node.ctx.depth > 0) { 830 const modui = modCache.get(node)?.ui('contentList') 831 if (modui?.blur || modui?.filter) { 832 if (!showHiddenReplies || node.ctx.depth > 1) { 833 if ((modui.blurs[0] || modui.filters[0]).type === 'muted') { 834 return HiddenReplyType.Muted 835 } 836 return HiddenReplyType.Hidden 837 } 838 } 839 840 if (!showHiddenReplies) { 841 const hiddenByThreadgate = threadgateRecordHiddenReplies.has( 842 node.post.uri, 843 ) 844 const authorIsViewer = node.post.author.did === currentDid 845 if (hiddenByThreadgate && !authorIsViewer) { 846 return HiddenReplyType.Hidden 847 } 848 } 849 } 850 851 if (!node.ctx.isHighlightedPost) { 852 yield node 853 } 854 855 if (node.replies?.length) { 856 let hiddenReplies = HiddenReplyType.None 857 for (const reply of node.replies) { 858 let hiddenReply = yield* flattenThreadReplies( 859 reply, 860 currentDid, 861 treeView, 862 modCache, 863 showHiddenReplies, 864 threadgateRecordHiddenReplies, 865 ) 866 if (hiddenReply > hiddenReplies) { 867 hiddenReplies = hiddenReply 868 } 869 if (!treeView && !node.ctx.isHighlightedPost) { 870 break 871 } 872 } 873 874 // show control to enable hidden replies 875 if (node.ctx.depth === 0) { 876 if (hiddenReplies === HiddenReplyType.Muted) { 877 yield SHOW_MUTED_REPLIES 878 } else if (hiddenReplies === HiddenReplyType.Hidden) { 879 yield SHOW_HIDDEN_REPLIES 880 } 881 } 882 } 883 } else if (node.type === 'not-found') { 884 yield node 885 } else if (node.type === 'blocked') { 886 yield node 887 } 888 return HiddenReplyType.None 889} 890 891function hasPwiOptOut(node: ThreadPost) { 892 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') 893} 894 895function hasBranchingReplies(node?: ThreadNode) { 896 if (!node) { 897 return false 898 } 899 if (node.type !== 'post') { 900 return false 901 } 902 if (!node.replies) { 903 return false 904 } 905 if (node.replies.length === 1) { 906 return hasBranchingReplies(node.replies[0]) 907 } 908 return true 909}