mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {memo, useRef, useState} from 'react' 2import {StyleSheet, useWindowDimensions, View} from 'react-native' 3import {runOnJS} from 'react-native-reanimated' 4import Animated from 'react-native-reanimated' 5import {useSafeAreaInsets} from 'react-native-safe-area-context' 6import { 7 AppBskyFeedDefs, 8 AppBskyFeedThreadgate, 9 moderatePost, 10} from '@atproto/api' 11import {msg, Trans} from '@lingui/macro' 12import {useLingui} from '@lingui/react' 13 14import {HITSLOP_10} from '#/lib/constants' 15import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 16import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' 17import {useSetTitle} from '#/lib/hooks/useSetTitle' 18import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 19import {clamp} from '#/lib/numbers' 20import {ScrollProvider} from '#/lib/ScrollContext' 21import {sanitizeDisplayName} from '#/lib/strings/display-names' 22import {cleanError} from '#/lib/strings/errors' 23import {isAndroid, isNative, isWeb} from '#/platform/detection' 24import {useModerationOpts} from '#/state/preferences/moderation-opts' 25import { 26 fillThreadModerationCache, 27 sortThread, 28 ThreadBlocked, 29 ThreadModerationCache, 30 ThreadNode, 31 ThreadNotFound, 32 ThreadPost, 33 usePostThreadQuery, 34} from '#/state/queries/post-thread' 35import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences' 36import {usePreferencesQuery} from '#/state/queries/preferences' 37import {useSession} from '#/state/session' 38import {useComposerControls} from '#/state/shell' 39import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 40import {List, 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 | undefined}) { 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 108 const {data: preferences} = usePreferencesQuery() 109 const { 110 isFetching, 111 isError: isThreadError, 112 error: threadError, 113 refetch, 114 data: {thread, threadgate} = {}, 115 dataUpdatedAt: fetchedAt, 116 } = usePostThreadQuery(uri) 117 118 // The original source of truth for these are the server settings. 119 const serverPrefs = preferences?.threadViewPrefs 120 const serverPrioritizeFollowedUsers = 121 serverPrefs?.prioritizeFollowedUsers ?? true 122 const serverTreeViewEnabled = serverPrefs?.lab_treeViewEnabled ?? false 123 const serverSortReplies = serverPrefs?.sort ?? 'hotness' 124 125 // However, we also need these to work locally for PWI (without persistance). 126 // So we're mirroring them locally. 127 const prioritizeFollowedUsers = serverPrioritizeFollowedUsers 128 const [treeViewEnabled, setTreeViewEnabled] = useState(serverTreeViewEnabled) 129 const [sortReplies, setSortReplies] = useState(serverSortReplies) 130 131 // We'll reset the local state if new server state flows down to us. 132 const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs) 133 if (prevServerPrefs !== serverPrefs) { 134 setPrevServerPrefs(serverPrefs) 135 setTreeViewEnabled(serverTreeViewEnabled) 136 setSortReplies(serverSortReplies) 137 } 138 139 // And we'll update the local state when mutating the server prefs. 140 const {mutate: mutateThreadViewPrefs} = useSetThreadViewPreferencesMutation() 141 function updateTreeViewEnabled(newTreeViewEnabled: boolean) { 142 setTreeViewEnabled(newTreeViewEnabled) 143 if (hasSession) { 144 mutateThreadViewPrefs({lab_treeViewEnabled: newTreeViewEnabled}) 145 } 146 } 147 function updateSortReplies(newSortReplies: string) { 148 setSortReplies(newSortReplies) 149 if (hasSession) { 150 mutateThreadViewPrefs({sort: newSortReplies}) 151 } 152 } 153 154 const treeView = React.useMemo( 155 () => treeViewEnabled && hasBranchingReplies(thread), 156 [treeViewEnabled, thread], 157 ) 158 159 const rootPost = thread?.type === 'post' ? thread.post : undefined 160 const rootPostRecord = thread?.type === 'post' ? thread.record : undefined 161 const threadgateRecord = threadgate?.record as 162 | AppBskyFeedThreadgate.Record 163 | undefined 164 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 165 threadgateRecord, 166 }) 167 168 const moderationOpts = useModerationOpts() 169 const isNoPwi = React.useMemo(() => { 170 const mod = 171 rootPost && moderationOpts 172 ? moderatePost(rootPost, moderationOpts) 173 : undefined 174 return !!mod 175 ?.ui('contentList') 176 .blurs.find( 177 cause => 178 cause.type === 'label' && 179 cause.labelDef.identifier === '!no-unauthenticated', 180 ) 181 }, [rootPost, moderationOpts]) 182 183 // Values used for proper rendering of parents 184 const ref = useRef<ListMethods>(null) 185 const highlightedPostRef = useRef<View | null>(null) 186 const [maxParents, setMaxParents] = React.useState( 187 isWeb ? Infinity : PARENTS_CHUNK_SIZE, 188 ) 189 const [maxReplies, setMaxReplies] = React.useState(50) 190 191 useSetTitle( 192 rootPost && !isNoPwi 193 ? `${sanitizeDisplayName( 194 rootPost.author.displayName || `@${rootPost.author.handle}`, 195 )}: "${rootPostRecord!.text}"` 196 : '', 197 ) 198 199 // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed. 200 // This ensures that the first render contains no parents--even if they are already available in the cache. 201 // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen. 202 // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead. 203 const [deferParents, setDeferParents] = React.useState(isNative) 204 205 const currentDid = currentAccount?.did 206 const threadModerationCache = React.useMemo(() => { 207 const cache: ThreadModerationCache = new WeakMap() 208 if (thread && moderationOpts) { 209 fillThreadModerationCache(cache, thread, moderationOpts) 210 } 211 return cache 212 }, [thread, moderationOpts]) 213 214 const [justPostedUris, setJustPostedUris] = React.useState( 215 () => new Set<string>(), 216 ) 217 218 const [fetchedAtCache] = React.useState(() => new Map<string, number>()) 219 const [randomCache] = React.useState(() => new Map<string, number>()) 220 const skeleton = React.useMemo(() => { 221 if (!thread) return null 222 return createThreadSkeleton( 223 sortThread( 224 thread, 225 { 226 // Prefer local state as the source of truth. 227 sort: sortReplies, 228 lab_treeViewEnabled: treeViewEnabled, 229 prioritizeFollowedUsers, 230 }, 231 threadModerationCache, 232 currentDid, 233 justPostedUris, 234 threadgateHiddenReplies, 235 fetchedAtCache, 236 fetchedAt, 237 randomCache, 238 ), 239 currentDid, 240 treeView, 241 threadModerationCache, 242 hiddenRepliesState !== HiddenRepliesState.Hide, 243 threadgateHiddenReplies, 244 ) 245 }, [ 246 thread, 247 prioritizeFollowedUsers, 248 sortReplies, 249 treeViewEnabled, 250 currentDid, 251 treeView, 252 threadModerationCache, 253 hiddenRepliesState, 254 justPostedUris, 255 threadgateHiddenReplies, 256 fetchedAtCache, 257 fetchedAt, 258 randomCache, 259 ]) 260 261 const error = React.useMemo(() => { 262 if (AppBskyFeedDefs.isNotFoundPost(thread)) { 263 return { 264 title: _(msg`Post not found`), 265 message: _(msg`The post may have been deleted.`), 266 } 267 } else if (skeleton?.highlightedPost.type === 'blocked') { 268 return { 269 title: _(msg`Post hidden`), 270 message: _( 271 msg`You have blocked the author or you have been blocked by the author.`, 272 ), 273 } 274 } else if (threadError?.message.startsWith('Post not found')) { 275 return { 276 title: _(msg`Post not found`), 277 message: _(msg`The post may have been deleted.`), 278 } 279 } else if (isThreadError) { 280 return { 281 message: threadError ? cleanError(threadError) : undefined, 282 } 283 } 284 285 return null 286 }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) 287 288 // construct content 289 const posts = React.useMemo(() => { 290 if (!skeleton) return [] 291 292 const {parents, highlightedPost, replies} = skeleton 293 let arr: RowItem[] = [] 294 if (highlightedPost.type === 'post') { 295 // We want to wait for parents to load before rendering. 296 // If you add something here, you'll need to update both 297 // maintainVisibleContentPosition and onContentSizeChange 298 // to "hold onto" the correct row instead of the first one. 299 300 if (!highlightedPost.ctx.isParentLoading && !deferParents) { 301 // When progressively revealing parents, rendering a placeholder 302 // here will cause scrolling jumps. Don't add it unless you test it. 303 // QT'ing this thread is a great way to test all the scrolling hacks: 304 // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o 305 306 // Everything is loaded 307 let startIndex = Math.max(0, parents.length - maxParents) 308 for (let i = startIndex; i < parents.length; i++) { 309 arr.push(parents[i]) 310 } 311 } 312 arr.push(highlightedPost) 313 if (!highlightedPost.post.viewer?.replyDisabled) { 314 arr.push(REPLY_PROMPT) 315 } 316 for (let i = 0; i < replies.length; i++) { 317 arr.push(replies[i]) 318 if (i === maxReplies) { 319 break 320 } 321 } 322 } 323 return arr 324 }, [skeleton, deferParents, maxParents, maxReplies]) 325 326 // This is only used on the web to keep the post in view when its parents load. 327 // On native, we rely on `maintainVisibleContentPosition` instead. 328 const didAdjustScrollWeb = useRef<boolean>(false) 329 const onContentSizeChangeWeb = React.useCallback(() => { 330 // only run once 331 if (didAdjustScrollWeb.current) { 332 return 333 } 334 // wait for loading to finish 335 if (thread?.type === 'post' && !!thread.parent) { 336 // Measure synchronously to avoid a layout jump. 337 const postNode = highlightedPostRef.current 338 const headerNode = headerRef.current 339 if (postNode && headerNode) { 340 let pageY = (postNode as any as Element).getBoundingClientRect().top 341 pageY -= (headerNode as any as Element).getBoundingClientRect().height 342 pageY = Math.max(0, pageY) 343 ref.current?.scrollToOffset({ 344 animated: false, 345 offset: pageY, 346 }) 347 } 348 didAdjustScrollWeb.current = true 349 } 350 }, [thread]) 351 352 // On native, we reveal parents in chunks. Although they're all already 353 // loaded and FlatList already has its own virtualization, unfortunately FlatList 354 // has a bug that causes the content to jump around if too many items are getting 355 // prepended at once. It also jumps around if items get prepended during scroll. 356 // To work around this, we prepend rows after scroll bumps against the top and rests. 357 const needsBumpMaxParents = React.useRef(false) 358 const onStartReached = React.useCallback(() => { 359 if (skeleton?.parents && maxParents < skeleton.parents.length) { 360 needsBumpMaxParents.current = true 361 } 362 }, [maxParents, skeleton?.parents]) 363 const bumpMaxParentsIfNeeded = React.useCallback(() => { 364 if (!isNative) { 365 return 366 } 367 if (needsBumpMaxParents.current) { 368 needsBumpMaxParents.current = false 369 setMaxParents(n => n + PARENTS_CHUNK_SIZE) 370 } 371 }, []) 372 const onScrollToTop = bumpMaxParentsIfNeeded 373 const onMomentumEnd = React.useCallback(() => { 374 'worklet' 375 runOnJS(bumpMaxParentsIfNeeded)() 376 }, [bumpMaxParentsIfNeeded]) 377 378 const onEndReached = React.useCallback(() => { 379 if (isFetching || posts.length < maxReplies) return 380 setMaxReplies(prev => prev + 50) 381 }, [isFetching, maxReplies, posts.length]) 382 383 const onPostReply = React.useCallback( 384 (postUri: string | undefined) => { 385 refetch() 386 if (postUri) { 387 setJustPostedUris(set => { 388 const nextSet = new Set(set) 389 nextSet.add(postUri) 390 return nextSet 391 }) 392 } 393 }, 394 [refetch], 395 ) 396 397 const {openComposer} = useComposerControls() 398 const onPressReply = React.useCallback(() => { 399 if (thread?.type !== 'post') { 400 return 401 } 402 openComposer({ 403 replyTo: { 404 uri: thread.post.uri, 405 cid: thread.post.cid, 406 text: thread.record.text, 407 author: thread.post.author, 408 embed: thread.post.embed, 409 moderation: threadModerationCache.get(thread), 410 }, 411 onPost: onPostReply, 412 }) 413 }, [openComposer, thread, onPostReply, threadModerationCache]) 414 415 const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled 416 const hasParents = 417 skeleton?.highlightedPost?.type === 'post' && 418 (skeleton.highlightedPost.ctx.isParentLoading || 419 Boolean(skeleton?.parents && skeleton.parents.length > 0)) 420 421 const renderItem = ({item, index}: {item: RowItem; index: number}) => { 422 if (item === REPLY_PROMPT && hasSession) { 423 return ( 424 <View> 425 {!isMobile && ( 426 <PostThreadComposePrompt onPressCompose={onPressReply} /> 427 )} 428 </View> 429 ) 430 } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { 431 return ( 432 <PostThreadShowHiddenReplies 433 type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'} 434 onPress={() => 435 setHiddenRepliesState( 436 item === SHOW_HIDDEN_REPLIES 437 ? HiddenRepliesState.Show 438 : HiddenRepliesState.ShowAndOverridePostHider, 439 ) 440 } 441 hideTopBorder={index === 0} 442 /> 443 ) 444 } else if (isThreadNotFound(item)) { 445 return ( 446 <View 447 style={[ 448 a.p_lg, 449 index !== 0 && a.border_t, 450 t.atoms.border_contrast_low, 451 t.atoms.bg_contrast_25, 452 ]}> 453 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> 454 <Trans>Deleted post.</Trans> 455 </Text> 456 </View> 457 ) 458 } else if (isThreadBlocked(item)) { 459 return ( 460 <View 461 style={[ 462 a.p_lg, 463 index !== 0 && a.border_t, 464 t.atoms.border_contrast_low, 465 t.atoms.bg_contrast_25, 466 ]}> 467 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> 468 <Trans>Blocked post.</Trans> 469 </Text> 470 </View> 471 ) 472 } else if (isThreadPost(item)) { 473 const prev = isThreadPost(posts[index - 1]) 474 ? (posts[index - 1] as ThreadPost) 475 : undefined 476 const next = isThreadPost(posts[index + 1]) 477 ? (posts[index + 1] as ThreadPost) 478 : undefined 479 const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth 480 const showParentReplyLine = 481 (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1 482 const hasUnrevealedParents = 483 index === 0 && skeleton?.parents && maxParents < skeleton.parents.length 484 485 if (!treeView && prev && item.ctx.hasMoreSelfThread) { 486 return <PostThreadLoadMore post={prev.post} /> 487 } 488 489 return ( 490 <View 491 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} 492 onLayout={deferParents ? () => setDeferParents(false) : undefined}> 493 <PostThreadItem 494 post={item.post} 495 record={item.record} 496 threadgateRecord={threadgateRecord ?? undefined} 497 moderation={threadModerationCache.get(item)} 498 treeView={treeView} 499 depth={item.ctx.depth} 500 prevPost={prev} 501 nextPost={next} 502 isHighlightedPost={item.ctx.isHighlightedPost} 503 hasMore={item.ctx.hasMore} 504 showChildReplyLine={showChildReplyLine} 505 showParentReplyLine={showParentReplyLine} 506 hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents} 507 overrideBlur={ 508 hiddenRepliesState === 509 HiddenRepliesState.ShowAndOverridePostHider && 510 item.ctx.depth > 0 511 } 512 onPostReply={onPostReply} 513 hideTopBorder={index === 0 && !item.ctx.isParentLoading} 514 /> 515 </View> 516 ) 517 } 518 return null 519 } 520 521 if (!thread || !preferences || error) { 522 return ( 523 <ListMaybePlaceholder 524 isLoading={!error} 525 isError={Boolean(error)} 526 noEmpty 527 onRetry={refetch} 528 errorTitle={error?.title} 529 errorMessage={error?.message} 530 /> 531 ) 532 } 533 534 return ( 535 <> 536 <Header.Outer headerRef={headerRef}> 537 <Header.BackButton /> 538 <Header.Content> 539 <Header.TitleText> 540 <Trans context="description">Post</Trans> 541 </Header.TitleText> 542 </Header.Content> 543 <Header.Slot> 544 <ThreadMenu 545 sortReplies={sortReplies} 546 treeViewEnabled={treeViewEnabled} 547 setSortReplies={updateSortReplies} 548 setTreeViewEnabled={updateTreeViewEnabled} 549 /> 550 </Header.Slot> 551 </Header.Outer> 552 553 <ScrollProvider onMomentumEnd={onMomentumEnd}> 554 <List 555 ref={ref} 556 data={posts} 557 renderItem={renderItem} 558 keyExtractor={keyExtractor} 559 onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} 560 onStartReached={onStartReached} 561 onEndReached={onEndReached} 562 onEndReachedThreshold={2} 563 onScrollToTop={onScrollToTop} 564 maintainVisibleContentPosition={ 565 isNative && hasParents 566 ? MAINTAIN_VISIBLE_CONTENT_POSITION 567 : undefined 568 } 569 // @ts-ignore our .web version only -prf 570 desktopFixedHeight 571 removeClippedSubviews={isAndroid ? false : undefined} 572 ListFooterComponent={ 573 <ListFooter 574 // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on 575 // initial render 576 isFetchingNextPage={isFetching} 577 error={cleanError(threadError)} 578 onRetry={refetch} 579 // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to 580 // work without causing weird jumps on web or glitches on native 581 height={windowHeight - 200} 582 /> 583 } 584 initialNumToRender={initialNumToRender} 585 windowSize={11} 586 sideBorders={false} 587 /> 588 </ScrollProvider> 589 {isMobile && canReply && hasSession && ( 590 <MobileComposePrompt onPressReply={onPressReply} /> 591 )} 592 </> 593 ) 594} 595 596let ThreadMenu = ({ 597 sortReplies, 598 treeViewEnabled, 599 setSortReplies, 600 setTreeViewEnabled, 601}: { 602 sortReplies: string 603 treeViewEnabled: boolean 604 setSortReplies: (newValue: string) => void 605 setTreeViewEnabled: (newValue: boolean) => void 606}): React.ReactNode => { 607 const {_} = useLingui() 608 return ( 609 <Menu.Root> 610 <Menu.Trigger label={_(msg`Thread options`)}> 611 {({props}) => ( 612 <Button 613 label={_(msg`Thread options`)} 614 size="small" 615 variant="ghost" 616 color="secondary" 617 shape="round" 618 hitSlop={HITSLOP_10} 619 {...props}> 620 <ButtonIcon icon={SettingsSlider} size="md" /> 621 </Button> 622 )} 623 </Menu.Trigger> 624 <Menu.Outer> 625 <Menu.LabelText> 626 <Trans>Show replies as</Trans> 627 </Menu.LabelText> 628 <Menu.Group> 629 <Menu.Item 630 label={_(msg`Linear`)} 631 onPress={() => { 632 setTreeViewEnabled(false) 633 }}> 634 <Menu.ItemText> 635 <Trans>Linear</Trans> 636 </Menu.ItemText> 637 <Menu.ItemRadio selected={!treeViewEnabled} /> 638 </Menu.Item> 639 <Menu.Item 640 label={_(msg`Threaded`)} 641 onPress={() => { 642 setTreeViewEnabled(true) 643 }}> 644 <Menu.ItemText> 645 <Trans>Threaded</Trans> 646 </Menu.ItemText> 647 <Menu.ItemRadio selected={treeViewEnabled} /> 648 </Menu.Item> 649 </Menu.Group> 650 <Menu.Divider /> 651 <Menu.LabelText> 652 <Trans>Reply sorting</Trans> 653 </Menu.LabelText> 654 <Menu.Group> 655 <Menu.Item 656 label={_(msg`Hot replies first`)} 657 onPress={() => { 658 setSortReplies('hotness') 659 }}> 660 <Menu.ItemText> 661 <Trans>Hot replies first</Trans> 662 </Menu.ItemText> 663 <Menu.ItemRadio selected={sortReplies === 'hotness'} /> 664 </Menu.Item> 665 <Menu.Item 666 label={_(msg`Oldest replies first`)} 667 onPress={() => { 668 setSortReplies('oldest') 669 }}> 670 <Menu.ItemText> 671 <Trans>Oldest replies first</Trans> 672 </Menu.ItemText> 673 <Menu.ItemRadio selected={sortReplies === 'oldest'} /> 674 </Menu.Item> 675 <Menu.Item 676 label={_(msg`Newest replies first`)} 677 onPress={() => { 678 setSortReplies('newest') 679 }}> 680 <Menu.ItemText> 681 <Trans>Newest replies first</Trans> 682 </Menu.ItemText> 683 <Menu.ItemRadio selected={sortReplies === 'newest'} /> 684 </Menu.Item> 685 <Menu.Item 686 label={_(msg`Most-liked replies first`)} 687 onPress={() => { 688 setSortReplies('most-likes') 689 }}> 690 <Menu.ItemText> 691 <Trans>Most-liked replies first</Trans> 692 </Menu.ItemText> 693 <Menu.ItemRadio selected={sortReplies === 'most-likes'} /> 694 </Menu.Item> 695 <Menu.Item 696 label={_(msg`Random (aka "Poster's Roulette")`)} 697 onPress={() => { 698 setSortReplies('random') 699 }}> 700 <Menu.ItemText> 701 <Trans>Random (aka "Poster's Roulette")</Trans> 702 </Menu.ItemText> 703 <Menu.ItemRadio selected={sortReplies === 'random'} /> 704 </Menu.Item> 705 </Menu.Group> 706 </Menu.Outer> 707 </Menu.Root> 708 ) 709} 710ThreadMenu = memo(ThreadMenu) 711 712function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { 713 const safeAreaInsets = useSafeAreaInsets() 714 const fabMinimalShellTransform = useMinimalShellFabTransform() 715 return ( 716 <Animated.View 717 style={[ 718 styles.prompt, 719 fabMinimalShellTransform, 720 { 721 bottom: clamp(safeAreaInsets.bottom, 13, 60), 722 }, 723 ]}> 724 <PostThreadComposePrompt onPressCompose={onPressReply} /> 725 </Animated.View> 726 ) 727} 728 729function isThreadPost(v: unknown): v is ThreadPost { 730 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' 731} 732 733function isThreadNotFound(v: unknown): v is ThreadNotFound { 734 return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found' 735} 736 737function isThreadBlocked(v: unknown): v is ThreadBlocked { 738 return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked' 739} 740 741function createThreadSkeleton( 742 node: ThreadNode, 743 currentDid: string | undefined, 744 treeView: boolean, 745 modCache: ThreadModerationCache, 746 showHiddenReplies: boolean, 747 threadgateRecordHiddenReplies: Set<string>, 748): ThreadSkeletonParts | null { 749 if (!node) return null 750 751 return { 752 parents: Array.from(flattenThreadParents(node, !!currentDid)), 753 highlightedPost: node, 754 replies: Array.from( 755 flattenThreadReplies( 756 node, 757 currentDid, 758 treeView, 759 modCache, 760 showHiddenReplies, 761 threadgateRecordHiddenReplies, 762 ), 763 ), 764 } 765} 766 767function* flattenThreadParents( 768 node: ThreadNode, 769 hasSession: boolean, 770): Generator<YieldedItem, void> { 771 if (node.type === 'post') { 772 if (node.parent) { 773 yield* flattenThreadParents(node.parent, hasSession) 774 } 775 if (!node.ctx.isHighlightedPost) { 776 yield node 777 } 778 } else if (node.type === 'not-found') { 779 yield node 780 } else if (node.type === 'blocked') { 781 yield node 782 } 783} 784 785// The enum is ordered to make them easy to merge 786enum HiddenReplyType { 787 None = 0, 788 Muted = 1, 789 Hidden = 2, 790} 791 792function* flattenThreadReplies( 793 node: ThreadNode, 794 currentDid: string | undefined, 795 treeView: boolean, 796 modCache: ThreadModerationCache, 797 showHiddenReplies: boolean, 798 threadgateRecordHiddenReplies: Set<string>, 799): Generator<YieldedItem, HiddenReplyType> { 800 if (node.type === 'post') { 801 // dont show pwi-opted-out posts to logged out users 802 if (!currentDid && hasPwiOptOut(node)) { 803 return HiddenReplyType.None 804 } 805 806 // handle blurred items 807 if (node.ctx.depth > 0) { 808 const modui = modCache.get(node)?.ui('contentList') 809 if (modui?.blur || modui?.filter) { 810 if (!showHiddenReplies || node.ctx.depth > 1) { 811 if ((modui.blurs[0] || modui.filters[0]).type === 'muted') { 812 return HiddenReplyType.Muted 813 } 814 return HiddenReplyType.Hidden 815 } 816 } 817 818 if (!showHiddenReplies) { 819 const hiddenByThreadgate = threadgateRecordHiddenReplies.has( 820 node.post.uri, 821 ) 822 const authorIsViewer = node.post.author.did === currentDid 823 if (hiddenByThreadgate && !authorIsViewer) { 824 return HiddenReplyType.Hidden 825 } 826 } 827 } 828 829 if (!node.ctx.isHighlightedPost) { 830 yield node 831 } 832 833 if (node.replies?.length) { 834 let hiddenReplies = HiddenReplyType.None 835 for (const reply of node.replies) { 836 let hiddenReply = yield* flattenThreadReplies( 837 reply, 838 currentDid, 839 treeView, 840 modCache, 841 showHiddenReplies, 842 threadgateRecordHiddenReplies, 843 ) 844 if (hiddenReply > hiddenReplies) { 845 hiddenReplies = hiddenReply 846 } 847 if (!treeView && !node.ctx.isHighlightedPost) { 848 break 849 } 850 } 851 852 // show control to enable hidden replies 853 if (node.ctx.depth === 0) { 854 if (hiddenReplies === HiddenReplyType.Muted) { 855 yield SHOW_MUTED_REPLIES 856 } else if (hiddenReplies === HiddenReplyType.Hidden) { 857 yield SHOW_HIDDEN_REPLIES 858 } 859 } 860 } 861 } else if (node.type === 'not-found') { 862 yield node 863 } else if (node.type === 'blocked') { 864 yield node 865 } 866 return HiddenReplyType.None 867} 868 869function hasPwiOptOut(node: ThreadPost) { 870 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') 871} 872 873function hasBranchingReplies(node?: ThreadNode) { 874 if (!node) { 875 return false 876 } 877 if (node.type !== 'post') { 878 return false 879 } 880 if (!node.replies) { 881 return false 882 } 883 if (node.replies.length === 1) { 884 return hasBranchingReplies(node.replies[0]) 885 } 886 return true 887} 888 889const styles = StyleSheet.create({ 890 prompt: { 891 // @ts-ignore web-only 892 position: isWeb ? 'fixed' : 'absolute', 893 left: 0, 894 right: 0, 895 }, 896})