my fork of the bluesky client

Split FeedSlice into FlatList rows (#6507)

authored by danabra.mov and committed by

GitHub cfc653a5 8d5e61e1

+188 -211
+116 -27
src/view/com/posts/Feed.tsx
··· 42 42 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' 43 43 import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' 44 44 import {FeedErrorMessage} from './FeedErrorMessage' 45 + import {FeedItem} from './FeedItem' 45 46 import {FeedShutdownMsg} from './FeedShutdownMsg' 46 - import {FeedSlice} from './FeedSlice' 47 + import {ViewFullThread} from './ViewFullThread' 47 48 48 - type FeedItem = 49 + type FeedRow = 49 50 | { 50 51 type: 'loading' 51 52 key: string ··· 72 73 slice: FeedPostSlice 73 74 } 74 75 | { 76 + type: 'sliceItem' 77 + key: string 78 + slice: FeedPostSlice 79 + indexInSlice: number 80 + showReplyTo: boolean 81 + } 82 + | { 83 + type: 'sliceViewFullThread' 84 + key: string 85 + uri: string 86 + } 87 + | { 75 88 type: 'interstitialFeeds' 76 89 key: string 77 90 params: { ··· 101 114 const progressGuideInterstitialType = 'interstitialProgressGuide' 102 115 const interstials: Record< 103 116 'following' | 'discover' | 'profile', 104 - (FeedItem & { 117 + (FeedRow & { 105 118 type: 106 119 | 'interstitialFeeds' 107 120 | 'interstitialFollows' ··· 139 152 ], 140 153 } 141 154 142 - export function getFeedPostSlice(feedItem: FeedItem): FeedPostSlice | null { 143 - if (feedItem.type === 'slice') { 144 - return feedItem.slice 155 + export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null { 156 + if (feedRow.type === 'sliceItem') { 157 + return feedRow.slice 145 158 } else { 146 159 return null 147 160 } ··· 303 316 } 304 317 }, [pollInterval]) 305 318 306 - const feedItems: FeedItem[] = React.useMemo(() => { 307 - let arr: FeedItem[] = [] 319 + const feedItems: FeedRow[] = React.useMemo(() => { 320 + let arr: FeedRow[] = [] 308 321 if (KNOWN_SHUTDOWN_FEEDS.includes(feedUri)) { 309 322 arr.push({ 310 323 type: 'feedShutdownMsg', ··· 324 337 }) 325 338 } else if (data) { 326 339 for (const page of data?.pages) { 327 - arr = arr.concat( 328 - page.slices.map(s => ({ 329 - type: 'slice', 330 - slice: s, 331 - key: s._reactKey, 332 - })), 333 - ) 340 + for (const slice of page.slices) { 341 + if (slice.isIncompleteThread && slice.items.length >= 3) { 342 + const beforeLast = slice.items.length - 2 343 + const last = slice.items.length - 1 344 + arr.push({ 345 + type: 'sliceItem', 346 + key: slice.items[0]._reactKey, 347 + slice: slice, 348 + indexInSlice: 0, 349 + showReplyTo: false, 350 + }) 351 + arr.push({ 352 + type: 'sliceViewFullThread', 353 + key: slice._reactKey + '-viewFullThread', 354 + uri: slice.items[0].uri, 355 + }) 356 + arr.push({ 357 + type: 'sliceItem', 358 + key: slice.items[beforeLast]._reactKey, 359 + slice: slice, 360 + indexInSlice: beforeLast, 361 + showReplyTo: 362 + slice.items[beforeLast].parentAuthor?.did !== 363 + slice.items[beforeLast].post.author.did, 364 + }) 365 + arr.push({ 366 + type: 'sliceItem', 367 + key: slice.items[last]._reactKey, 368 + slice: slice, 369 + indexInSlice: last, 370 + showReplyTo: false, 371 + }) 372 + } else { 373 + for (let i = 0; i < slice.items.length; i++) { 374 + arr.push({ 375 + type: 'sliceItem', 376 + key: slice.items[i]._reactKey, 377 + slice: slice, 378 + indexInSlice: i, 379 + showReplyTo: i === 0, 380 + }) 381 + } 382 + } 383 + } 334 384 } 335 385 } 336 386 if (isError && !isEmpty) { ··· 454 504 // = 455 505 456 506 const renderItem = React.useCallback( 457 - ({item, index}: ListRenderItemInfo<FeedItem>) => { 458 - if (item.type === 'empty') { 507 + ({item: row, index: rowIndex}: ListRenderItemInfo<FeedRow>) => { 508 + if (row.type === 'empty') { 459 509 return renderEmptyState() 460 - } else if (item.type === 'error') { 510 + } else if (row.type === 'error') { 461 511 return ( 462 512 <FeedErrorMessage 463 513 feedDesc={feed} ··· 466 516 savedFeedConfig={savedFeedConfig} 467 517 /> 468 518 ) 469 - } else if (item.type === 'loadMoreError') { 519 + } else if (row.type === 'loadMoreError') { 470 520 return ( 471 521 <LoadMoreRetryBtn 472 522 label={_( ··· 475 525 onPress={onPressRetryLoadMore} 476 526 /> 477 527 ) 478 - } else if (item.type === 'loading') { 528 + } else if (row.type === 'loading') { 479 529 return <PostFeedLoadingPlaceholder /> 480 - } else if (item.type === 'feedShutdownMsg') { 530 + } else if (row.type === 'feedShutdownMsg') { 481 531 return <FeedShutdownMsg feedUri={feedUri} /> 482 - } else if (item.type === feedInterstitialType) { 532 + } else if (row.type === feedInterstitialType) { 483 533 return <SuggestedFeeds /> 484 - } else if (item.type === followInterstitialType) { 534 + } else if (row.type === followInterstitialType) { 485 535 return <SuggestedFollows feed={feed} /> 486 - } else if (item.type === progressGuideInterstitialType) { 536 + } else if (row.type === progressGuideInterstitialType) { 487 537 return <ProgressGuide /> 488 - } else if (item.type === 'slice') { 489 - if (item.slice.isFallbackMarker) { 538 + } else if (row.type === 'sliceItem') { 539 + const slice = row.slice 540 + if (slice.isFallbackMarker) { 490 541 // HACK 491 542 // tell the user we fell back to discover 492 543 // see home.ts (feed api) for more info 493 544 // -prf 494 545 return <DiscoverFallbackHeader /> 495 546 } 496 - return <FeedSlice slice={item.slice} hideTopBorder={index === 0} /> 547 + const indexInSlice = row.indexInSlice 548 + const item = slice.items[indexInSlice] 549 + return ( 550 + <FeedItem 551 + post={item.post} 552 + record={item.record} 553 + reason={indexInSlice === 0 ? slice.reason : undefined} 554 + feedContext={slice.feedContext} 555 + moderation={item.moderation} 556 + parentAuthor={item.parentAuthor} 557 + showReplyTo={row.showReplyTo} 558 + isThreadParent={isThreadParentAt(slice.items, indexInSlice)} 559 + isThreadChild={isThreadChildAt(slice.items, indexInSlice)} 560 + isThreadLastChild={ 561 + isThreadChildAt(slice.items, indexInSlice) && 562 + slice.items.length === indexInSlice + 1 563 + } 564 + isParentBlocked={item.isParentBlocked} 565 + isParentNotFound={item.isParentNotFound} 566 + hideTopBorder={rowIndex === 0 && indexInSlice === 0} 567 + rootPost={slice.items[0].post} 568 + /> 569 + ) 570 + } else if (row.type === 'sliceViewFullThread') { 571 + return <ViewFullThread uri={row.uri} /> 497 572 } else { 498 573 return null 499 574 } ··· 574 649 const styles = StyleSheet.create({ 575 650 feedFooter: {paddingTop: 20}, 576 651 }) 652 + 653 + function isThreadParentAt<T>(arr: Array<T>, i: number) { 654 + if (arr.length === 1) { 655 + return false 656 + } 657 + return i < arr.length - 1 658 + } 659 + 660 + function isThreadChildAt<T>(arr: Array<T>, i: number) { 661 + if (arr.length === 1) { 662 + return false 663 + } 664 + return i > 0 665 + }
-184
src/view/com/posts/FeedSlice.tsx
··· 1 - import React, {memo} from 'react' 2 - import {StyleSheet, View} from 'react-native' 3 - import Svg, {Circle, Line} from 'react-native-svg' 4 - import {AtUri} from '@atproto/api' 5 - import {Trans} from '@lingui/macro' 6 - 7 - import {usePalette} from '#/lib/hooks/usePalette' 8 - import {makeProfileLink} from '#/lib/routes/links' 9 - import {FeedPostSlice} from '#/state/queries/post-feed' 10 - import {useInteractionState} from '#/components/hooks/useInteractionState' 11 - import {SubtleWebHover} from '#/components/SubtleWebHover' 12 - import {Link} from '../util/Link' 13 - import {Text} from '../util/text/Text' 14 - import {FeedItem} from './FeedItem' 15 - 16 - let FeedSlice = ({ 17 - slice, 18 - hideTopBorder, 19 - }: { 20 - slice: FeedPostSlice 21 - hideTopBorder?: boolean 22 - }): React.ReactNode => { 23 - if (slice.isIncompleteThread && slice.items.length >= 3) { 24 - const beforeLast = slice.items.length - 2 25 - const last = slice.items.length - 1 26 - return ( 27 - <> 28 - <FeedItem 29 - key={slice.items[0]._reactKey} 30 - post={slice.items[0].post} 31 - record={slice.items[0].record} 32 - reason={slice.reason} 33 - feedContext={slice.feedContext} 34 - parentAuthor={slice.items[0].parentAuthor} 35 - showReplyTo={false} 36 - moderation={slice.items[0].moderation} 37 - isThreadParent={isThreadParentAt(slice.items, 0)} 38 - isThreadChild={isThreadChildAt(slice.items, 0)} 39 - hideTopBorder={hideTopBorder} 40 - isParentBlocked={slice.items[0].isParentBlocked} 41 - isParentNotFound={slice.items[0].isParentNotFound} 42 - rootPost={slice.items[0].post} 43 - /> 44 - <ViewFullThread uri={slice.items[0].uri} /> 45 - <FeedItem 46 - key={slice.items[beforeLast]._reactKey} 47 - post={slice.items[beforeLast].post} 48 - record={slice.items[beforeLast].record} 49 - reason={undefined} 50 - feedContext={slice.feedContext} 51 - parentAuthor={slice.items[beforeLast].parentAuthor} 52 - showReplyTo={ 53 - slice.items[beforeLast].parentAuthor?.did !== 54 - slice.items[beforeLast].post.author.did 55 - } 56 - moderation={slice.items[beforeLast].moderation} 57 - isThreadParent={isThreadParentAt(slice.items, beforeLast)} 58 - isThreadChild={isThreadChildAt(slice.items, beforeLast)} 59 - isParentBlocked={slice.items[beforeLast].isParentBlocked} 60 - isParentNotFound={slice.items[beforeLast].isParentNotFound} 61 - rootPost={slice.items[0].post} 62 - /> 63 - <FeedItem 64 - key={slice.items[last]._reactKey} 65 - post={slice.items[last].post} 66 - record={slice.items[last].record} 67 - reason={undefined} 68 - feedContext={slice.feedContext} 69 - parentAuthor={slice.items[last].parentAuthor} 70 - showReplyTo={false} 71 - moderation={slice.items[last].moderation} 72 - isThreadParent={isThreadParentAt(slice.items, last)} 73 - isThreadChild={isThreadChildAt(slice.items, last)} 74 - isParentBlocked={slice.items[last].isParentBlocked} 75 - isParentNotFound={slice.items[last].isParentNotFound} 76 - isThreadLastChild 77 - rootPost={slice.items[0].post} 78 - /> 79 - </> 80 - ) 81 - } 82 - 83 - return ( 84 - <> 85 - {slice.items.map((item, i) => ( 86 - <FeedItem 87 - key={item._reactKey} 88 - post={slice.items[i].post} 89 - record={slice.items[i].record} 90 - reason={i === 0 ? slice.reason : undefined} 91 - feedContext={slice.feedContext} 92 - moderation={slice.items[i].moderation} 93 - parentAuthor={slice.items[i].parentAuthor} 94 - showReplyTo={i === 0} 95 - isThreadParent={isThreadParentAt(slice.items, i)} 96 - isThreadChild={isThreadChildAt(slice.items, i)} 97 - isThreadLastChild={ 98 - isThreadChildAt(slice.items, i) && slice.items.length === i + 1 99 - } 100 - isParentBlocked={slice.items[i].isParentBlocked} 101 - isParentNotFound={slice.items[i].isParentNotFound} 102 - hideTopBorder={hideTopBorder && i === 0} 103 - rootPost={slice.items[0].post} 104 - /> 105 - ))} 106 - </> 107 - ) 108 - } 109 - FeedSlice = memo(FeedSlice) 110 - export {FeedSlice} 111 - 112 - function ViewFullThread({uri}: {uri: string}) { 113 - const { 114 - state: hover, 115 - onIn: onHoverIn, 116 - onOut: onHoverOut, 117 - } = useInteractionState() 118 - const pal = usePalette('default') 119 - const itemHref = React.useMemo(() => { 120 - const urip = new AtUri(uri) 121 - return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) 122 - }, [uri]) 123 - 124 - return ( 125 - <Link 126 - style={[styles.viewFullThread]} 127 - href={itemHref} 128 - asAnchor 129 - noFeedback 130 - onPointerEnter={onHoverIn} 131 - onPointerLeave={onHoverOut}> 132 - <SubtleWebHover 133 - hover={hover} 134 - // adjust position for visual alignment - the actual box has lots of top padding and not much bottom padding -sfn 135 - style={{top: 8, bottom: -5}} 136 - /> 137 - <View style={styles.viewFullThreadDots}> 138 - <Svg width="4" height="40"> 139 - <Line 140 - x1="2" 141 - y1="0" 142 - x2="2" 143 - y2="15" 144 - stroke={pal.colors.replyLine} 145 - strokeWidth="2" 146 - /> 147 - <Circle cx="2" cy="22" r="1.5" fill={pal.colors.replyLineDot} /> 148 - <Circle cx="2" cy="28" r="1.5" fill={pal.colors.replyLineDot} /> 149 - <Circle cx="2" cy="34" r="1.5" fill={pal.colors.replyLineDot} /> 150 - </Svg> 151 - </View> 152 - 153 - <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}> 154 - <Trans>View full thread</Trans> 155 - </Text> 156 - </Link> 157 - ) 158 - } 159 - 160 - const styles = StyleSheet.create({ 161 - viewFullThread: { 162 - flexDirection: 'row', 163 - gap: 10, 164 - paddingLeft: 18, 165 - }, 166 - viewFullThreadDots: { 167 - width: 42, 168 - alignItems: 'center', 169 - }, 170 - }) 171 - 172 - function isThreadParentAt<T>(arr: Array<T>, i: number) { 173 - if (arr.length === 1) { 174 - return false 175 - } 176 - return i < arr.length - 1 177 - } 178 - 179 - function isThreadChildAt<T>(arr: Array<T>, i: number) { 180 - if (arr.length === 1) { 181 - return false 182 - } 183 - return i > 0 184 - }
+72
src/view/com/posts/ViewFullThread.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import Svg, {Circle, Line} from 'react-native-svg' 4 + import {AtUri} from '@atproto/api' 5 + import {Trans} from '@lingui/macro' 6 + 7 + import {usePalette} from '#/lib/hooks/usePalette' 8 + import {makeProfileLink} from '#/lib/routes/links' 9 + import {useInteractionState} from '#/components/hooks/useInteractionState' 10 + import {SubtleWebHover} from '#/components/SubtleWebHover' 11 + import {Link} from '../util/Link' 12 + import {Text} from '../util/text/Text' 13 + 14 + export function ViewFullThread({uri}: {uri: string}) { 15 + const { 16 + state: hover, 17 + onIn: onHoverIn, 18 + onOut: onHoverOut, 19 + } = useInteractionState() 20 + const pal = usePalette('default') 21 + const itemHref = React.useMemo(() => { 22 + const urip = new AtUri(uri) 23 + return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) 24 + }, [uri]) 25 + 26 + return ( 27 + <Link 28 + style={[styles.viewFullThread]} 29 + href={itemHref} 30 + asAnchor 31 + noFeedback 32 + onPointerEnter={onHoverIn} 33 + onPointerLeave={onHoverOut}> 34 + <SubtleWebHover 35 + hover={hover} 36 + // adjust position for visual alignment - the actual box has lots of top padding and not much bottom padding -sfn 37 + style={{top: 8, bottom: -5}} 38 + /> 39 + <View style={styles.viewFullThreadDots}> 40 + <Svg width="4" height="40"> 41 + <Line 42 + x1="2" 43 + y1="0" 44 + x2="2" 45 + y2="15" 46 + stroke={pal.colors.replyLine} 47 + strokeWidth="2" 48 + /> 49 + <Circle cx="2" cy="22" r="1.5" fill={pal.colors.replyLineDot} /> 50 + <Circle cx="2" cy="28" r="1.5" fill={pal.colors.replyLineDot} /> 51 + <Circle cx="2" cy="34" r="1.5" fill={pal.colors.replyLineDot} /> 52 + </Svg> 53 + </View> 54 + 55 + <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}> 56 + <Trans>View full thread</Trans> 57 + </Text> 58 + </Link> 59 + ) 60 + } 61 + 62 + const styles = StyleSheet.create({ 63 + viewFullThread: { 64 + flexDirection: 'row', 65 + gap: 10, 66 + paddingLeft: 18, 67 + }, 68 + viewFullThreadDots: { 69 + width: 42, 70 + alignItems: 'center', 71 + }, 72 + })