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