An ATproto social media client -- with an independent Appview.
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor feed manipulation and rendering to be more robust (#297)

authored by

Paul Frazee and committed by
GitHub
c50a20d2 93df9836

+360 -260
+186
src/lib/api/feed-manip.ts
··· 1 + import {AppBskyFeedFeedViewPost} from '@atproto/api' 2 + type FeedViewPost = AppBskyFeedFeedViewPost.Main 3 + 4 + export type FeedTunerFn = ( 5 + tuner: FeedTuner, 6 + slices: FeedViewPostsSlice[], 7 + ) => void 8 + 9 + export class FeedViewPostsSlice { 10 + constructor(public items: FeedViewPost[] = []) {} 11 + 12 + get uri() { 13 + if (this.isReply) { 14 + return this.items[1].post.uri 15 + } 16 + return this.items[0].post.uri 17 + } 18 + 19 + get ts() { 20 + if (this.items[0].reason?.indexedAt) { 21 + return this.items[0].reason.indexedAt as string 22 + } 23 + return this.items[0].post.indexedAt 24 + } 25 + 26 + get isThread() { 27 + return ( 28 + this.items.length > 1 && 29 + this.items.every( 30 + item => item.post.author.did === this.items[0].post.author.did, 31 + ) 32 + ) 33 + } 34 + 35 + get isReply() { 36 + return this.items.length === 2 && !this.isThread 37 + } 38 + 39 + get rootItem() { 40 + if (this.isReply) { 41 + return this.items[1] 42 + } 43 + return this.items[0] 44 + } 45 + 46 + containsUri(uri: string) { 47 + return !!this.items.find(item => item.post.uri === uri) 48 + } 49 + 50 + insert(item: FeedViewPost) { 51 + const selfReplyUri = getSelfReplyUri(item) 52 + const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri) 53 + if (i !== -1) { 54 + this.items.splice(i + 1, 0, item) 55 + } else { 56 + this.items.push(item) 57 + } 58 + } 59 + 60 + flattenReplyParent() { 61 + if (this.items[0].reply?.parent) { 62 + this.items.splice(0, 0, {post: this.items[0].reply?.parent}) 63 + } 64 + } 65 + 66 + logSelf() { 67 + console.log( 68 + `- Slice ${this.items.length}${this.isThread ? ' (thread)' : ''} -`, 69 + ) 70 + for (const item of this.items) { 71 + console.log( 72 + ` ${item.reason ? `RP by ${item.reason.by.handle}: ` : ''}${ 73 + item.post.author.handle 74 + }: ${item.reply ? `(Reply ${item.reply.parent.author.handle}) ` : ''}${ 75 + item.post.record.text 76 + }`, 77 + ) 78 + } 79 + } 80 + } 81 + 82 + export class FeedTuner { 83 + seenUris: Set<string> = new Set() 84 + 85 + constructor() {} 86 + 87 + reset() { 88 + this.seenUris.clear() 89 + } 90 + 91 + tune( 92 + feed: FeedViewPost[], 93 + tunerFns: FeedTunerFn[] = [], 94 + ): FeedViewPostsSlice[] { 95 + const slices: FeedViewPostsSlice[] = [] 96 + 97 + // arrange the posts into thread slices 98 + for (let i = feed.length - 1; i >= 0; i--) { 99 + const item = feed[i] 100 + 101 + const selfReplyUri = getSelfReplyUri(item) 102 + if (selfReplyUri) { 103 + const parent = slices.find(item2 => item2.containsUri(selfReplyUri)) 104 + if (parent) { 105 + parent.insert(item) 106 + continue 107 + } 108 + } 109 + slices.unshift(new FeedViewPostsSlice([item])) 110 + } 111 + 112 + // remove any items already "seen" 113 + for (let i = slices.length - 1; i >= 0; i--) { 114 + if (this.seenUris.has(slices[i].uri)) { 115 + slices.splice(i, 1) 116 + } 117 + } 118 + 119 + // turn non-threads with reply parents into threads 120 + for (const slice of slices) { 121 + if ( 122 + !slice.isThread && 123 + !slice.items[0].reason && 124 + slice.items[0].reply?.parent && 125 + !this.seenUris.has(slice.items[0].reply?.parent.uri) 126 + ) { 127 + slice.flattenReplyParent() 128 + } 129 + } 130 + 131 + // sort by slice roots' timestamps 132 + slices.sort((a, b) => b.ts.localeCompare(a.ts)) 133 + 134 + // run the custom tuners 135 + for (const tunerFn of tunerFns) { 136 + tunerFn(this, slices) 137 + } 138 + 139 + for (const slice of slices) { 140 + for (const item of slice.items) { 141 + this.seenUris.add(item.post.uri) 142 + } 143 + slice.logSelf() 144 + } 145 + 146 + return slices 147 + } 148 + 149 + static dedupReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { 150 + // remove duplicates caused by reposts 151 + for (let i = 0; i < slices.length; i++) { 152 + const item1 = slices[i] 153 + for (let j = i + 1; j < slices.length; j++) { 154 + const item2 = slices[j] 155 + if (item2.isThread) { 156 + // dont dedup items that are rendering in a thread as this can cause rendering errors 157 + continue 158 + } 159 + if (item1.containsUri(item2.items[0].post.uri)) { 160 + slices.splice(j, 1) 161 + j-- 162 + } 163 + } 164 + } 165 + } 166 + 167 + static likedRepliesOnly(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { 168 + // remove any replies without any likes 169 + for (let i = slices.length - 1; i >= 0; i--) { 170 + if (slices[i].isThread) { 171 + continue 172 + } 173 + const item = slices[i].rootItem 174 + const isRepost = Boolean(item.reason) 175 + if (item.reply && !isRepost && item.post.upvoteCount === 0) { 176 + slices.splice(i, 1) 177 + } 178 + } 179 + } 180 + } 181 + 182 + function getSelfReplyUri(item: FeedViewPost): string | undefined { 183 + return item.reply?.parent.author.did === item.post.author.did 184 + ? item.reply?.parent.uri 185 + : undefined 186 + }
+125 -229
src/state/models/feed-view.ts
··· 23 23 mergePosts, 24 24 } from 'lib/api/build-suggested-posts' 25 25 26 + import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' 27 + 26 28 const PAGE_SIZE = 30 27 29 28 30 let _idCounter = 0 29 31 30 - type FeedViewPostWithThreadMeta = FeedViewPost & { 31 - _isThreadParent?: boolean 32 - _isThreadChildElided?: boolean 33 - _isThreadChild?: boolean 34 - } 35 - 36 32 export class FeedItemModel { 37 33 // ui state 38 34 _reactKey: string = '' 39 - _isThreadParent: boolean = false 40 - _isThreadChildElided: boolean = false 41 - _isThreadChild: boolean = false 42 - _hideParent: boolean = true // used to avoid dup post rendering while showing some parents 43 35 44 36 // data 45 37 post: PostView 46 38 postRecord?: AppBskyFeedPost.Record 47 39 reply?: FeedViewPost['reply'] 48 - replyParent?: FeedItemModel 49 40 reason?: FeedViewPost['reason'] 50 41 richText?: RichText 51 42 52 43 constructor( 53 44 public rootStore: RootStoreModel, 54 45 reactKey: string, 55 - v: FeedViewPostWithThreadMeta, 46 + v: FeedViewPost, 56 47 ) { 57 48 this._reactKey = reactKey 58 49 this.post = v.post ··· 78 69 ) 79 70 } 80 71 this.reply = v.reply 81 - if (v.reply?.parent) { 82 - this.replyParent = new FeedItemModel(rootStore, '', { 83 - post: v.reply.parent, 84 - }) 85 - } 86 72 this.reason = v.reason 87 - this._isThreadParent = v._isThreadParent || false 88 - this._isThreadChild = v._isThreadChild || false 89 - this._isThreadChildElided = v._isThreadChildElided || false 90 73 makeAutoObservable(this, {rootStore: false}) 91 74 } 92 75 93 76 copy(v: FeedViewPost) { 94 77 this.post = v.post 95 78 this.reply = v.reply 96 - if (v.reply?.parent) { 97 - this.replyParent = new FeedItemModel(this.rootStore, '', { 98 - post: v.reply.parent, 99 - }) 100 - } else { 101 - this.replyParent = undefined 102 - } 103 79 this.reason = v.reason 104 80 } 105 81 106 - get _isRenderingAsThread() { 107 - return ( 108 - this._isThreadParent || this._isThreadChild || this._isThreadChildElided 109 - ) 82 + copyMetrics(v: FeedViewPost) { 83 + this.post.replyCount = v.post.replyCount 84 + this.post.repostCount = v.post.repostCount 85 + this.post.upvoteCount = v.post.upvoteCount 86 + this.post.viewer = v.post.viewer 110 87 } 111 88 112 89 get reasonRepost(): ReasonRepost | undefined { ··· 192 169 } 193 170 } 194 171 172 + export class FeedSliceModel { 173 + // ui state 174 + _reactKey: string = '' 175 + 176 + // data 177 + items: FeedItemModel[] = [] 178 + 179 + constructor( 180 + public rootStore: RootStoreModel, 181 + reactKey: string, 182 + slice: FeedViewPostsSlice, 183 + ) { 184 + this._reactKey = reactKey 185 + for (const item of slice.items) { 186 + this.items.push( 187 + new FeedItemModel(rootStore, `item-${_idCounter++}`, item), 188 + ) 189 + } 190 + makeAutoObservable(this, {rootStore: false}) 191 + } 192 + 193 + get uri() { 194 + if (this.isReply) { 195 + return this.items[1].post.uri 196 + } 197 + return this.items[0].post.uri 198 + } 199 + 200 + get isThread() { 201 + return ( 202 + this.items.length > 1 && 203 + this.items.every( 204 + item => item.post.author.did === this.items[0].post.author.did, 205 + ) 206 + ) 207 + } 208 + 209 + get isReply() { 210 + return this.items.length === 2 && !this.isThread 211 + } 212 + 213 + get rootItem() { 214 + if (this.isReply) { 215 + return this.items[1] 216 + } 217 + return this.items[0] 218 + } 219 + 220 + containsUri(uri: string) { 221 + return !!this.items.find(item => item.post.uri === uri) 222 + } 223 + 224 + isThreadParentAt(i: number) { 225 + if (this.items.length === 1) { 226 + return false 227 + } 228 + return i < this.items.length - 1 229 + } 230 + 231 + isThreadChildAt(i: number) { 232 + if (this.items.length === 1) { 233 + return false 234 + } 235 + return i > 0 236 + } 237 + } 238 + 195 239 export class FeedModel { 196 240 // state 197 241 isLoading = false ··· 203 247 hasMore = true 204 248 loadMoreCursor: string | undefined 205 249 pollCursor: string | undefined 250 + tuner = new FeedTuner() 206 251 207 252 // used to linearize async modifications to state 208 253 private lock = new AwaitLock() 209 254 210 255 // data 211 - feed: FeedItemModel[] = [] 256 + slices: FeedSliceModel[] = [] 212 257 213 258 constructor( 214 259 public rootStore: RootStoreModel, ··· 228 273 } 229 274 230 275 get hasContent() { 231 - return this.feed.length !== 0 276 + return this.slices.length !== 0 232 277 } 233 278 234 279 get hasError() { ··· 241 286 242 287 get nonReplyFeed() { 243 288 if (this.feedType === 'author') { 244 - return this.feed.filter(item => { 289 + return this.slices.filter(slice => { 245 290 const params = this.params as GetAuthorFeed.QueryParams 291 + const item = slice.rootItem 246 292 const isRepost = 247 - item.reply && 248 - (item?.reasonRepost?.by?.handle === params.author || 249 - item?.reasonRepost?.by?.did === params.author) 250 - 293 + item?.reasonRepost?.by?.handle === params.author || 294 + item?.reasonRepost?.by?.did === params.author 251 295 return ( 252 296 !item.reply || // not a reply 253 - isRepost || 254 - ((item._isThreadParent || // but allow if it's a thread by the user 255 - item._isThreadChild) && 297 + isRepost || // but allow if it's a repost 298 + (slice.isThread && // or a thread by the user 256 299 item.reply?.root.author.did === item.post.author.did) 257 300 ) 258 301 }) 259 - } else if (this.feedType === 'home') { 260 - return this.feed.filter(item => { 261 - const isRepost = Boolean(item?.reasonRepost) 262 - return ( 263 - !item.reply || // not a reply 264 - isRepost || // but allow if it's a repost or thread 265 - item._isThreadParent || 266 - item._isThreadChild || 267 - item.post.upvoteCount >= 2 268 - ) 269 - }) 270 302 } else { 271 - return this.feed 303 + return this.slices 272 304 } 273 305 } 274 306 ··· 292 324 this.hasMore = true 293 325 this.loadMoreCursor = undefined 294 326 this.pollCursor = undefined 295 - this.feed = [] 327 + this.slices = [] 328 + this.tuner.reset() 296 329 } 297 330 298 331 switchFeedType(feedType: 'home' | 'suggested') { ··· 314 347 await this.lock.acquireAsync() 315 348 try { 316 349 this.setHasNewLatest(false) 350 + this.tuner.reset() 317 351 this._xLoading(isRefreshing) 318 352 try { 319 353 const res = await this._getFeed({limit: PAGE_SIZE}) ··· 401 435 update = bundleAsync(async () => { 402 436 await this.lock.acquireAsync() 403 437 try { 404 - if (!this.feed.length) { 438 + if (!this.slices.length) { 405 439 return 406 440 } 407 441 this._xLoading() 408 - let numToFetch = this.feed.length 442 + let numToFetch = this.slices.length 409 443 let cursor 410 444 try { 411 445 do { ··· 464 498 onPostDeleted(uri: string) { 465 499 let i 466 500 do { 467 - i = this.feed.findIndex(item => item.post.uri === uri) 501 + i = this.slices.findIndex(slice => slice.containsUri(uri)) 468 502 if (i !== -1) { 469 - this.feed.splice(i, 1) 503 + this.slices.splice(i, 1) 470 504 } 471 505 } while (i !== -1) 472 506 } ··· 506 540 ) { 507 541 this.loadMoreCursor = res.data.cursor 508 542 this.hasMore = !!this.loadMoreCursor 509 - const orgLen = this.feed.length 510 543 511 - const reorgedFeed = preprocessFeed(res.data.feed) 544 + const slices = this.tuner.tune( 545 + res.data.feed, 546 + this.feedType === 'home' 547 + ? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly] 548 + : [], 549 + ) 512 550 513 - const toAppend: FeedItemModel[] = [] 514 - for (const item of reorgedFeed) { 515 - const itemModel = new FeedItemModel( 551 + const toAppend: FeedSliceModel[] = [] 552 + for (const slice of slices) { 553 + const sliceModel = new FeedSliceModel( 516 554 this.rootStore, 517 555 `item-${_idCounter++}`, 518 - item, 556 + slice, 519 557 ) 520 - toAppend.push(itemModel) 558 + toAppend.push(sliceModel) 521 559 } 522 560 runInAction(() => { 523 561 if (replace) { 524 - this.feed = toAppend 562 + this.slices = toAppend 525 563 } else { 526 - this.feed = this.feed.concat(toAppend) 564 + this.slices = this.slices.concat(toAppend) 527 565 } 528 - dedupReposts(this.feed) 529 - dedupParents(this.feed.slice(orgLen)) // we slice to avoid modifying rendering of already-shown posts 530 566 }) 531 567 } 532 568 ··· 535 571 ) { 536 572 this.pollCursor = res.data.feed[0]?.post.uri 537 573 538 - const toPrepend: FeedItemModel[] = [] 539 - for (const item of res.data.feed) { 540 - if (this.feed.find(item2 => item2.post.uri === item.post.uri)) { 541 - break // stop here - we've hit a post we already have 542 - } 574 + const slices = this.tuner.tune( 575 + res.data.feed, 576 + this.feedType === 'home' 577 + ? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly] 578 + : [], 579 + ) 543 580 544 - const itemModel = new FeedItemModel( 581 + const toPrepend: FeedSliceModel[] = [] 582 + for (const slice of slices) { 583 + const itemModel = new FeedSliceModel( 545 584 this.rootStore, 546 585 `item-${_idCounter++}`, 547 - item, 586 + slice, 548 587 ) 549 588 toPrepend.push(itemModel) 550 589 } 551 590 runInAction(() => { 552 - this.feed = toPrepend.concat(this.feed) 591 + this.slices = toPrepend.concat(this.slices) 553 592 }) 554 593 } 555 594 556 595 private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { 557 596 for (const item of res.data.feed) { 558 - const existingItem = this.feed.find( 559 - // HACK: need to find the reposts' item, so we have to check for that -prf 560 - item2 => 561 - item.post.uri === item2.post.uri && 562 - // @ts-ignore todo 563 - item.reason?.by?.did === item2.reason?.by?.did, 597 + const existingSlice = this.slices.find(slice => 598 + slice.containsUri(item.post.uri), 564 599 ) 565 - if (existingItem) { 566 - existingItem.copy(item) 600 + if (existingSlice) { 601 + const existingItem = existingSlice.items.find( 602 + item2 => item2.post.uri === item.post.uri, 603 + ) 604 + if (existingItem) { 605 + existingItem.copyMetrics(item) 606 + } 567 607 } 568 608 } 569 609 } ··· 601 641 } 602 642 } 603 643 } 604 - 605 - interface Slice { 606 - index: number 607 - length: number 608 - } 609 - function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] { 610 - const reorg: FeedViewPostWithThreadMeta[] = [] 611 - 612 - // phase one: identify threads and reorganize them into the feed so 613 - // that they are in order and marked as part of a thread 614 - for (let i = feed.length - 1; i >= 0; i--) { 615 - const item = feed[i] as FeedViewPostWithThreadMeta 616 - 617 - const selfReplyUri = getSelfReplyUri(item) 618 - if (selfReplyUri) { 619 - const parentIndex = reorg.findIndex( 620 - item2 => item2.post.uri === selfReplyUri, 621 - ) 622 - if (parentIndex !== -1 && !reorg[parentIndex]._isThreadParent) { 623 - reorg[parentIndex]._isThreadParent = true 624 - item._isThreadChild = true 625 - reorg.splice(parentIndex + 1, 0, item) 626 - continue 627 - } 628 - } 629 - reorg.unshift(item) 630 - } 631 - 632 - // phase two: reorder the feed so that the timestamp of the 633 - // last post in a thread establishes its ordering 634 - let threadSlices: Slice[] = identifyThreadSlices(reorg) 635 - for (const slice of threadSlices) { 636 - const removed: FeedViewPostWithThreadMeta[] = reorg.splice( 637 - slice.index, 638 - slice.length, 639 - ) 640 - const targetDate = new Date(ts(removed[removed.length - 1])) 641 - let newIndex = reorg.findIndex(item => new Date(ts(item)) < targetDate) 642 - if (newIndex === -1) { 643 - newIndex = reorg.length 644 - } 645 - reorg.splice(newIndex, 0, ...removed) 646 - slice.index = newIndex 647 - } 648 - 649 - // phase three: compress any threads that are longer than 3 posts 650 - let removedCount = 0 651 - // phase 2 moved posts around, so we need to re-identify the slice indices 652 - threadSlices = identifyThreadSlices(reorg) 653 - for (const slice of threadSlices) { 654 - if (slice.length > 3) { 655 - reorg.splice(slice.index - removedCount + 1, slice.length - 3) 656 - if (reorg[slice.index - removedCount]) { 657 - // ^ sanity check 658 - reorg[slice.index - removedCount]._isThreadChildElided = true 659 - } 660 - removedCount += slice.length - 3 661 - } 662 - } 663 - 664 - return reorg 665 - } 666 - 667 - function identifyThreadSlices(feed: FeedViewPost[]): Slice[] { 668 - let activeSlice = -1 669 - let threadSlices: Slice[] = [] 670 - for (let i = 0; i < feed.length; i++) { 671 - const item = feed[i] as FeedViewPostWithThreadMeta 672 - if (activeSlice === -1) { 673 - if (item._isThreadParent) { 674 - activeSlice = i 675 - } 676 - } else { 677 - if (!item._isThreadChild) { 678 - threadSlices.push({index: activeSlice, length: i - activeSlice}) 679 - if (item._isThreadParent) { 680 - activeSlice = i 681 - } else { 682 - activeSlice = -1 683 - } 684 - } 685 - } 686 - } 687 - if (activeSlice !== -1) { 688 - threadSlices.push({index: activeSlice, length: feed.length - activeSlice}) 689 - } 690 - return threadSlices 691 - } 692 - 693 - // WARNING: mutates `feed` 694 - function dedupReposts(feed: FeedItemModel[]) { 695 - // remove duplicates caused by reposts 696 - for (let i = 0; i < feed.length; i++) { 697 - const item1 = feed[i] 698 - for (let j = i + 1; j < feed.length; j++) { 699 - const item2 = feed[j] 700 - if (item2._isRenderingAsThread) { 701 - // dont dedup items that are rendering in a thread as this can cause rendering errors 702 - continue 703 - } 704 - if (item1.post.uri === item2.post.uri) { 705 - feed.splice(j, 1) 706 - j-- 707 - } 708 - } 709 - } 710 - } 711 - 712 - // WARNING: mutates `feed` 713 - function dedupParents(feed: FeedItemModel[]) { 714 - // only show parents that aren't already in the feed 715 - for (let i = 0; i < feed.length; i++) { 716 - const item1 = feed[i] 717 - if (!item1.replyParent || item1._isThreadChild) { 718 - continue 719 - } 720 - let hideParent = false 721 - for (let j = 0; j < feed.length; j++) { 722 - const item2 = feed[j] 723 - if ( 724 - item1.replyParent.post.uri === item2.post.uri || // the post itself is there 725 - (j < i && item1.replyParent.post.uri === item2.replyParent?.post.uri) // another reply already showed it 726 - ) { 727 - hideParent = true 728 - break 729 - } 730 - } 731 - item1._hideParent = hideParent 732 - } 733 - } 734 - 735 - function getSelfReplyUri(item: FeedViewPost): string | undefined { 736 - return item.reply?.parent.author.did === item.post.author.did 737 - ? item.reply?.parent.uri 738 - : undefined 739 - } 740 - 741 - function ts(item: FeedViewPost | FeedItemModel): string { 742 - if (item.reason?.indexedAt) { 743 - // @ts-ignore need better type checks 744 - return item.reason.indexedAt 745 - } 746 - return item.post.indexedAt 747 - }
+1 -1
src/state/models/ui/profile.ts
··· 100 100 if (this.selectedView === Sections.Posts) { 101 101 arr = this.feed.nonReplyFeed 102 102 } else { 103 - arr = this.feed.feed.slice() 103 + arr = this.feed.slices.slice() 104 104 } 105 105 if (!this.feed.hasMore) { 106 106 arr = arr.concat([ProfileUiModel.END_ITEM])
+4 -8
src/view/com/posts/Feed.tsx
··· 16 16 import {ErrorMessage} from '../util/error/ErrorMessage' 17 17 import {Button} from '../util/forms/Button' 18 18 import {FeedModel} from 'state/models/feed-view' 19 - import {FeedItem} from './FeedItem' 19 + import {FeedSlice} from './FeedSlice' 20 20 import {OnScrollCb} from 'lib/hooks/useOnMainScroll' 21 21 import {s} from 'lib/styles' 22 22 import {useAnalytics} from 'lib/analytics' ··· 61 61 if (feed.isEmpty) { 62 62 feedItems = feedItems.concat([EMPTY_FEED_ITEM]) 63 63 } else { 64 - feedItems = feedItems.concat(feed.nonReplyFeed) 64 + feedItems = feedItems.concat(feed.slices) 65 65 } 66 66 } 67 67 return feedItems 68 - }, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.nonReplyFeed]) 68 + }, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.slices]) 69 69 70 70 // events 71 71 // = ··· 92 92 // rendering 93 93 // = 94 94 95 - // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf 96 - // VirtualizedList: You have a large list that is slow to update - make sure your 97 - // renderItem function renders components that follow React performance best practices 98 - // like PureComponent, shouldComponentUpdate, etc 99 95 const renderItem = React.useCallback( 100 96 ({item}: {item: any}) => { 101 97 if (item === EMPTY_FEED_ITEM) { ··· 138 134 /> 139 135 ) 140 136 } 141 - return <FeedItem item={item} showFollowBtn={showPostFollowBtn} /> 137 + return <FeedSlice slice={item} showFollowBtn={showPostFollowBtn} /> 142 138 }, 143 139 [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation], 144 140 )
+11 -17
src/view/com/posts/FeedItem.tsx
··· 26 26 27 27 export const FeedItem = observer(function ({ 28 28 item, 29 - showReplyLine, 29 + isThreadChild, 30 + isThreadParent, 30 31 showFollowBtn, 31 32 ignoreMuteFor, 32 33 }: { 33 34 item: FeedItemModel 35 + isThreadChild?: boolean 36 + isThreadParent?: boolean 34 37 showReplyLine?: boolean 35 38 showFollowBtn?: boolean 36 39 ignoreMuteFor?: string ··· 110 113 return <View /> 111 114 } 112 115 113 - const isChild = 114 - item._isThreadChild || (!item.reason && !item._hideParent && item.reply) 115 - const isSmallTop = isChild && item._isThreadChild 116 - const isNoTop = isChild && !item._isThreadChild 116 + const isSmallTop = isThreadChild 117 + const isNoTop = false //isChild && !item._isThreadChild 117 118 const isMuted = 118 119 item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did 119 120 const outerStyles = [ ··· 122 123 {borderColor: pal.colors.border}, 123 124 isSmallTop ? styles.outerSmallTop : undefined, 124 125 isNoTop ? styles.outerNoTop : undefined, 125 - item._isThreadParent ? styles.outerNoBottom : undefined, 126 + isThreadParent ? styles.outerNoBottom : undefined, 126 127 ] 127 128 128 129 return ( 129 130 <PostMutedWrapper isMuted={isMuted}> 130 - {isChild && !item._isThreadChild && item.replyParent ? ( 131 - <FeedItem 132 - item={item.replyParent} 133 - showReplyLine 134 - ignoreMuteFor={ignoreMuteFor} 135 - /> 136 - ) : undefined} 137 131 <Link style={outerStyles} href={itemHref} title={itemTitle} noFeedback> 138 - {item._isThreadChild && ( 132 + {isThreadChild && ( 139 133 <View 140 134 style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} 141 135 /> 142 136 )} 143 - {(showReplyLine || item._isThreadParent) && ( 137 + {isThreadParent && ( 144 138 <View 145 139 style={[ 146 140 styles.bottomReplyLine, ··· 199 193 declarationCid={item.post.author.declaration.cid} 200 194 showFollowBtn={showFollowBtn} 201 195 /> 202 - {!isChild && replyAuthorDid !== '' && ( 196 + {!isThreadChild && replyAuthorDid !== '' && ( 203 197 <View style={[s.flexRow, s.mb2, s.alignCenter]}> 204 198 <FontAwesomeIcon 205 199 icon="reply" ··· 259 253 </View> 260 254 </View> 261 255 </Link> 262 - {item._isThreadChildElided ? ( 256 + {false /*isThreadChildElided*/ ? ( 263 257 <Link 264 258 style={[pal.view, styles.viewFullThread]} 265 259 href={itemHref}
+28
src/view/com/posts/FeedSlice.tsx
··· 1 + import React from 'react' 2 + import {FeedSliceModel} from 'state/models/feed-view' 3 + import {FeedItem} from './FeedItem' 4 + 5 + export function FeedSlice({ 6 + slice, 7 + showFollowBtn, 8 + ignoreMuteFor, 9 + }: { 10 + slice: FeedSliceModel 11 + showFollowBtn?: boolean 12 + ignoreMuteFor?: string 13 + }) { 14 + return ( 15 + <> 16 + {slice.items.map((item, i) => ( 17 + <FeedItem 18 + key={item._reactKey} 19 + item={item} 20 + isThreadParent={slice.isThreadParentAt(i)} 21 + isThreadChild={slice.isThreadChildAt(i)} 22 + showFollowBtn={showFollowBtn} 23 + ignoreMuteFor={ignoreMuteFor} 24 + /> 25 + ))} 26 + </> 27 + ) 28 + }
+5 -5
src/view/screens/Profile.tsx
··· 6 6 import {withAuthRequired} from 'view/com/auth/withAuthRequired' 7 7 import {ViewSelector} from '../com/util/ViewSelector' 8 8 import {CenteredView} from '../com/util/Views' 9 - import {ProfileUiModel, Sections} from 'state/models/ui/profile' 9 + import {ProfileUiModel} from 'state/models/ui/profile' 10 10 import {useStores} from 'state/index' 11 - import {FeedItemModel} from 'state/models/feed-view' 11 + import {FeedSliceModel} from 'state/models/feed-view' 12 12 import {ProfileHeader} from '../com/profile/ProfileHeader' 13 - import {FeedItem} from '../com/posts/FeedItem' 13 + import {FeedSlice} from '../com/posts/FeedSlice' 14 14 import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder' 15 15 import {ErrorScreen} from '../com/util/error/ErrorScreen' 16 16 import {ErrorMessage} from '../com/util/error/ErrorMessage' ··· 123 123 style={styles.emptyState} 124 124 /> 125 125 ) 126 - } else if (item instanceof FeedItemModel) { 127 - return <FeedItem item={item} ignoreMuteFor={uiState.profile.did} /> 126 + } else if (item instanceof FeedSliceModel) { 127 + return <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} /> 128 128 } 129 129 return <View /> 130 130 },