about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/lib/api/feed-manip.ts186
-rw-r--r--src/state/models/feed-view.ts354
-rw-r--r--src/state/models/ui/profile.ts2
-rw-r--r--src/view/com/posts/Feed.tsx12
-rw-r--r--src/view/com/posts/FeedItem.tsx28
-rw-r--r--src/view/com/posts/FeedSlice.tsx28
-rw-r--r--src/view/screens/Profile.tsx10
7 files changed, 360 insertions, 260 deletions
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
new file mode 100644
index 000000000..00938be93
--- /dev/null
+++ b/src/lib/api/feed-manip.ts
@@ -0,0 +1,186 @@
+import {AppBskyFeedFeedViewPost} from '@atproto/api'
+type FeedViewPost = AppBskyFeedFeedViewPost.Main
+
+export type FeedTunerFn = (
+  tuner: FeedTuner,
+  slices: FeedViewPostsSlice[],
+) => void
+
+export class FeedViewPostsSlice {
+  constructor(public items: FeedViewPost[] = []) {}
+
+  get uri() {
+    if (this.isReply) {
+      return this.items[1].post.uri
+    }
+    return this.items[0].post.uri
+  }
+
+  get ts() {
+    if (this.items[0].reason?.indexedAt) {
+      return this.items[0].reason.indexedAt as string
+    }
+    return this.items[0].post.indexedAt
+  }
+
+  get isThread() {
+    return (
+      this.items.length > 1 &&
+      this.items.every(
+        item => item.post.author.did === this.items[0].post.author.did,
+      )
+    )
+  }
+
+  get isReply() {
+    return this.items.length === 2 && !this.isThread
+  }
+
+  get rootItem() {
+    if (this.isReply) {
+      return this.items[1]
+    }
+    return this.items[0]
+  }
+
+  containsUri(uri: string) {
+    return !!this.items.find(item => item.post.uri === uri)
+  }
+
+  insert(item: FeedViewPost) {
+    const selfReplyUri = getSelfReplyUri(item)
+    const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri)
+    if (i !== -1) {
+      this.items.splice(i + 1, 0, item)
+    } else {
+      this.items.push(item)
+    }
+  }
+
+  flattenReplyParent() {
+    if (this.items[0].reply?.parent) {
+      this.items.splice(0, 0, {post: this.items[0].reply?.parent})
+    }
+  }
+
+  logSelf() {
+    console.log(
+      `- Slice ${this.items.length}${this.isThread ? ' (thread)' : ''} -`,
+    )
+    for (const item of this.items) {
+      console.log(
+        `  ${item.reason ? `RP by ${item.reason.by.handle}: ` : ''}${
+          item.post.author.handle
+        }: ${item.reply ? `(Reply ${item.reply.parent.author.handle}) ` : ''}${
+          item.post.record.text
+        }`,
+      )
+    }
+  }
+}
+
+export class FeedTuner {
+  seenUris: Set<string> = new Set()
+
+  constructor() {}
+
+  reset() {
+    this.seenUris.clear()
+  }
+
+  tune(
+    feed: FeedViewPost[],
+    tunerFns: FeedTunerFn[] = [],
+  ): FeedViewPostsSlice[] {
+    const slices: FeedViewPostsSlice[] = []
+
+    // arrange the posts into thread slices
+    for (let i = feed.length - 1; i >= 0; i--) {
+      const item = feed[i]
+
+      const selfReplyUri = getSelfReplyUri(item)
+      if (selfReplyUri) {
+        const parent = slices.find(item2 => item2.containsUri(selfReplyUri))
+        if (parent) {
+          parent.insert(item)
+          continue
+        }
+      }
+      slices.unshift(new FeedViewPostsSlice([item]))
+    }
+
+    // remove any items already "seen"
+    for (let i = slices.length - 1; i >= 0; i--) {
+      if (this.seenUris.has(slices[i].uri)) {
+        slices.splice(i, 1)
+      }
+    }
+
+    // turn non-threads with reply parents into threads
+    for (const slice of slices) {
+      if (
+        !slice.isThread &&
+        !slice.items[0].reason &&
+        slice.items[0].reply?.parent &&
+        !this.seenUris.has(slice.items[0].reply?.parent.uri)
+      ) {
+        slice.flattenReplyParent()
+      }
+    }
+
+    // sort by slice roots' timestamps
+    slices.sort((a, b) => b.ts.localeCompare(a.ts))
+
+    // run the custom tuners
+    for (const tunerFn of tunerFns) {
+      tunerFn(this, slices)
+    }
+
+    for (const slice of slices) {
+      for (const item of slice.items) {
+        this.seenUris.add(item.post.uri)
+      }
+      slice.logSelf()
+    }
+
+    return slices
+  }
+
+  static dedupReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
+    // remove duplicates caused by reposts
+    for (let i = 0; i < slices.length; i++) {
+      const item1 = slices[i]
+      for (let j = i + 1; j < slices.length; j++) {
+        const item2 = slices[j]
+        if (item2.isThread) {
+          // dont dedup items that are rendering in a thread as this can cause rendering errors
+          continue
+        }
+        if (item1.containsUri(item2.items[0].post.uri)) {
+          slices.splice(j, 1)
+          j--
+        }
+      }
+    }
+  }
+
+  static likedRepliesOnly(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
+    // remove any replies without any likes
+    for (let i = slices.length - 1; i >= 0; i--) {
+      if (slices[i].isThread) {
+        continue
+      }
+      const item = slices[i].rootItem
+      const isRepost = Boolean(item.reason)
+      if (item.reply && !isRepost && item.post.upvoteCount === 0) {
+        slices.splice(i, 1)
+      }
+    }
+  }
+}
+
+function getSelfReplyUri(item: FeedViewPost): string | undefined {
+  return item.reply?.parent.author.did === item.post.author.did
+    ? item.reply?.parent.uri
+    : undefined
+}
diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts
index e27712d11..42b753b24 100644
--- a/src/state/models/feed-view.ts
+++ b/src/state/models/feed-view.ts
@@ -23,36 +23,27 @@ import {
   mergePosts,
 } from 'lib/api/build-suggested-posts'
 
+import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
+
 const PAGE_SIZE = 30
 
 let _idCounter = 0
 
-type FeedViewPostWithThreadMeta = FeedViewPost & {
-  _isThreadParent?: boolean
-  _isThreadChildElided?: boolean
-  _isThreadChild?: boolean
-}
-
 export class FeedItemModel {
   // ui state
   _reactKey: string = ''
-  _isThreadParent: boolean = false
-  _isThreadChildElided: boolean = false
-  _isThreadChild: boolean = false
-  _hideParent: boolean = true // used to avoid dup post rendering while showing some parents
 
   // data
   post: PostView
   postRecord?: AppBskyFeedPost.Record
   reply?: FeedViewPost['reply']
-  replyParent?: FeedItemModel
   reason?: FeedViewPost['reason']
   richText?: RichText
 
   constructor(
     public rootStore: RootStoreModel,
     reactKey: string,
-    v: FeedViewPostWithThreadMeta,
+    v: FeedViewPost,
   ) {
     this._reactKey = reactKey
     this.post = v.post
@@ -78,35 +69,21 @@ export class FeedItemModel {
       )
     }
     this.reply = v.reply
-    if (v.reply?.parent) {
-      this.replyParent = new FeedItemModel(rootStore, '', {
-        post: v.reply.parent,
-      })
-    }
     this.reason = v.reason
-    this._isThreadParent = v._isThreadParent || false
-    this._isThreadChild = v._isThreadChild || false
-    this._isThreadChildElided = v._isThreadChildElided || false
     makeAutoObservable(this, {rootStore: false})
   }
 
   copy(v: FeedViewPost) {
     this.post = v.post
     this.reply = v.reply
-    if (v.reply?.parent) {
-      this.replyParent = new FeedItemModel(this.rootStore, '', {
-        post: v.reply.parent,
-      })
-    } else {
-      this.replyParent = undefined
-    }
     this.reason = v.reason
   }
 
-  get _isRenderingAsThread() {
-    return (
-      this._isThreadParent || this._isThreadChild || this._isThreadChildElided
-    )
+  copyMetrics(v: FeedViewPost) {
+    this.post.replyCount = v.post.replyCount
+    this.post.repostCount = v.post.repostCount
+    this.post.upvoteCount = v.post.upvoteCount
+    this.post.viewer = v.post.viewer
   }
 
   get reasonRepost(): ReasonRepost | undefined {
@@ -192,6 +169,73 @@ export class FeedItemModel {
   }
 }
 
+export class FeedSliceModel {
+  // ui state
+  _reactKey: string = ''
+
+  // data
+  items: FeedItemModel[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    reactKey: string,
+    slice: FeedViewPostsSlice,
+  ) {
+    this._reactKey = reactKey
+    for (const item of slice.items) {
+      this.items.push(
+        new FeedItemModel(rootStore, `item-${_idCounter++}`, item),
+      )
+    }
+    makeAutoObservable(this, {rootStore: false})
+  }
+
+  get uri() {
+    if (this.isReply) {
+      return this.items[1].post.uri
+    }
+    return this.items[0].post.uri
+  }
+
+  get isThread() {
+    return (
+      this.items.length > 1 &&
+      this.items.every(
+        item => item.post.author.did === this.items[0].post.author.did,
+      )
+    )
+  }
+
+  get isReply() {
+    return this.items.length === 2 && !this.isThread
+  }
+
+  get rootItem() {
+    if (this.isReply) {
+      return this.items[1]
+    }
+    return this.items[0]
+  }
+
+  containsUri(uri: string) {
+    return !!this.items.find(item => item.post.uri === uri)
+  }
+
+  isThreadParentAt(i: number) {
+    if (this.items.length === 1) {
+      return false
+    }
+    return i < this.items.length - 1
+  }
+
+  isThreadChildAt(i: number) {
+    if (this.items.length === 1) {
+      return false
+    }
+    return i > 0
+  }
+}
+
 export class FeedModel {
   // state
   isLoading = false
@@ -203,12 +247,13 @@ export class FeedModel {
   hasMore = true
   loadMoreCursor: string | undefined
   pollCursor: string | undefined
+  tuner = new FeedTuner()
 
   // used to linearize async modifications to state
   private lock = new AwaitLock()
 
   // data
-  feed: FeedItemModel[] = []
+  slices: FeedSliceModel[] = []
 
   constructor(
     public rootStore: RootStoreModel,
@@ -228,7 +273,7 @@ export class FeedModel {
   }
 
   get hasContent() {
-    return this.feed.length !== 0
+    return this.slices.length !== 0
   }
 
   get hasError() {
@@ -241,34 +286,21 @@ export class FeedModel {
 
   get nonReplyFeed() {
     if (this.feedType === 'author') {
-      return this.feed.filter(item => {
+      return this.slices.filter(slice => {
         const params = this.params as GetAuthorFeed.QueryParams
+        const item = slice.rootItem
         const isRepost =
-          item.reply &&
-          (item?.reasonRepost?.by?.handle === params.author ||
-            item?.reasonRepost?.by?.did === params.author)
-
+          item?.reasonRepost?.by?.handle === params.author ||
+          item?.reasonRepost?.by?.did === params.author
         return (
           !item.reply || // not a reply
-          isRepost ||
-          ((item._isThreadParent || // but allow if it's a thread by the user
-            item._isThreadChild) &&
+          isRepost || // but allow if it's a repost
+          (slice.isThread && // or a thread by the user
             item.reply?.root.author.did === item.post.author.did)
         )
       })
-    } else if (this.feedType === 'home') {
-      return this.feed.filter(item => {
-        const isRepost = Boolean(item?.reasonRepost)
-        return (
-          !item.reply || // not a reply
-          isRepost || // but allow if it's a repost or thread
-          item._isThreadParent ||
-          item._isThreadChild ||
-          item.post.upvoteCount >= 2
-        )
-      })
     } else {
-      return this.feed
+      return this.slices
     }
   }
 
@@ -292,7 +324,8 @@ export class FeedModel {
     this.hasMore = true
     this.loadMoreCursor = undefined
     this.pollCursor = undefined
-    this.feed = []
+    this.slices = []
+    this.tuner.reset()
   }
 
   switchFeedType(feedType: 'home' | 'suggested') {
@@ -314,6 +347,7 @@ export class FeedModel {
     await this.lock.acquireAsync()
     try {
       this.setHasNewLatest(false)
+      this.tuner.reset()
       this._xLoading(isRefreshing)
       try {
         const res = await this._getFeed({limit: PAGE_SIZE})
@@ -401,11 +435,11 @@ export class FeedModel {
   update = bundleAsync(async () => {
     await this.lock.acquireAsync()
     try {
-      if (!this.feed.length) {
+      if (!this.slices.length) {
         return
       }
       this._xLoading()
-      let numToFetch = this.feed.length
+      let numToFetch = this.slices.length
       let cursor
       try {
         do {
@@ -464,9 +498,9 @@ export class FeedModel {
   onPostDeleted(uri: string) {
     let i
     do {
-      i = this.feed.findIndex(item => item.post.uri === uri)
+      i = this.slices.findIndex(slice => slice.containsUri(uri))
       if (i !== -1) {
-        this.feed.splice(i, 1)
+        this.slices.splice(i, 1)
       }
     } while (i !== -1)
   }
@@ -506,27 +540,29 @@ export class FeedModel {
   ) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
-    const orgLen = this.feed.length
 
-    const reorgedFeed = preprocessFeed(res.data.feed)
+    const slices = this.tuner.tune(
+      res.data.feed,
+      this.feedType === 'home'
+        ? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
+        : [],
+    )
 
-    const toAppend: FeedItemModel[] = []
-    for (const item of reorgedFeed) {
-      const itemModel = new FeedItemModel(
+    const toAppend: FeedSliceModel[] = []
+    for (const slice of slices) {
+      const sliceModel = new FeedSliceModel(
         this.rootStore,
         `item-${_idCounter++}`,
-        item,
+        slice,
       )
-      toAppend.push(itemModel)
+      toAppend.push(sliceModel)
     }
     runInAction(() => {
       if (replace) {
-        this.feed = toAppend
+        this.slices = toAppend
       } else {
-        this.feed = this.feed.concat(toAppend)
+        this.slices = this.slices.concat(toAppend)
       }
-      dedupReposts(this.feed)
-      dedupParents(this.feed.slice(orgLen)) // we slice to avoid modifying rendering of already-shown posts
     })
   }
 
@@ -535,35 +571,39 @@ export class FeedModel {
   ) {
     this.pollCursor = res.data.feed[0]?.post.uri
 
-    const toPrepend: FeedItemModel[] = []
-    for (const item of res.data.feed) {
-      if (this.feed.find(item2 => item2.post.uri === item.post.uri)) {
-        break // stop here - we've hit a post we already have
-      }
+    const slices = this.tuner.tune(
+      res.data.feed,
+      this.feedType === 'home'
+        ? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
+        : [],
+    )
 
-      const itemModel = new FeedItemModel(
+    const toPrepend: FeedSliceModel[] = []
+    for (const slice of slices) {
+      const itemModel = new FeedSliceModel(
         this.rootStore,
         `item-${_idCounter++}`,
-        item,
+        slice,
       )
       toPrepend.push(itemModel)
     }
     runInAction(() => {
-      this.feed = toPrepend.concat(this.feed)
+      this.slices = toPrepend.concat(this.slices)
     })
   }
 
   private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
     for (const item of res.data.feed) {
-      const existingItem = this.feed.find(
-        // HACK: need to find the reposts' item, so we have to check for that -prf
-        item2 =>
-          item.post.uri === item2.post.uri &&
-          // @ts-ignore todo
-          item.reason?.by?.did === item2.reason?.by?.did,
+      const existingSlice = this.slices.find(slice =>
+        slice.containsUri(item.post.uri),
       )
-      if (existingItem) {
-        existingItem.copy(item)
+      if (existingSlice) {
+        const existingItem = existingSlice.items.find(
+          item2 => item2.post.uri === item.post.uri,
+        )
+        if (existingItem) {
+          existingItem.copyMetrics(item)
+        }
       }
     }
   }
@@ -601,147 +641,3 @@ export class FeedModel {
     }
   }
 }
-
-interface Slice {
-  index: number
-  length: number
-}
-function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] {
-  const reorg: FeedViewPostWithThreadMeta[] = []
-
-  // phase one: identify threads and reorganize them into the feed so
-  // that they are in order and marked as part of a thread
-  for (let i = feed.length - 1; i >= 0; i--) {
-    const item = feed[i] as FeedViewPostWithThreadMeta
-
-    const selfReplyUri = getSelfReplyUri(item)
-    if (selfReplyUri) {
-      const parentIndex = reorg.findIndex(
-        item2 => item2.post.uri === selfReplyUri,
-      )
-      if (parentIndex !== -1 && !reorg[parentIndex]._isThreadParent) {
-        reorg[parentIndex]._isThreadParent = true
-        item._isThreadChild = true
-        reorg.splice(parentIndex + 1, 0, item)
-        continue
-      }
-    }
-    reorg.unshift(item)
-  }
-
-  // phase two: reorder the feed so that the timestamp of the
-  // last post in a thread establishes its ordering
-  let threadSlices: Slice[] = identifyThreadSlices(reorg)
-  for (const slice of threadSlices) {
-    const removed: FeedViewPostWithThreadMeta[] = reorg.splice(
-      slice.index,
-      slice.length,
-    )
-    const targetDate = new Date(ts(removed[removed.length - 1]))
-    let newIndex = reorg.findIndex(item => new Date(ts(item)) < targetDate)
-    if (newIndex === -1) {
-      newIndex = reorg.length
-    }
-    reorg.splice(newIndex, 0, ...removed)
-    slice.index = newIndex
-  }
-
-  // phase three: compress any threads that are longer than 3 posts
-  let removedCount = 0
-  // phase 2 moved posts around, so we need to re-identify the slice indices
-  threadSlices = identifyThreadSlices(reorg)
-  for (const slice of threadSlices) {
-    if (slice.length > 3) {
-      reorg.splice(slice.index - removedCount + 1, slice.length - 3)
-      if (reorg[slice.index - removedCount]) {
-        // ^ sanity check
-        reorg[slice.index - removedCount]._isThreadChildElided = true
-      }
-      removedCount += slice.length - 3
-    }
-  }
-
-  return reorg
-}
-
-function identifyThreadSlices(feed: FeedViewPost[]): Slice[] {
-  let activeSlice = -1
-  let threadSlices: Slice[] = []
-  for (let i = 0; i < feed.length; i++) {
-    const item = feed[i] as FeedViewPostWithThreadMeta
-    if (activeSlice === -1) {
-      if (item._isThreadParent) {
-        activeSlice = i
-      }
-    } else {
-      if (!item._isThreadChild) {
-        threadSlices.push({index: activeSlice, length: i - activeSlice})
-        if (item._isThreadParent) {
-          activeSlice = i
-        } else {
-          activeSlice = -1
-        }
-      }
-    }
-  }
-  if (activeSlice !== -1) {
-    threadSlices.push({index: activeSlice, length: feed.length - activeSlice})
-  }
-  return threadSlices
-}
-
-// WARNING: mutates `feed`
-function dedupReposts(feed: FeedItemModel[]) {
-  // remove duplicates caused by reposts
-  for (let i = 0; i < feed.length; i++) {
-    const item1 = feed[i]
-    for (let j = i + 1; j < feed.length; j++) {
-      const item2 = feed[j]
-      if (item2._isRenderingAsThread) {
-        // dont dedup items that are rendering in a thread as this can cause rendering errors
-        continue
-      }
-      if (item1.post.uri === item2.post.uri) {
-        feed.splice(j, 1)
-        j--
-      }
-    }
-  }
-}
-
-// WARNING: mutates `feed`
-function dedupParents(feed: FeedItemModel[]) {
-  // only show parents that aren't already in the feed
-  for (let i = 0; i < feed.length; i++) {
-    const item1 = feed[i]
-    if (!item1.replyParent || item1._isThreadChild) {
-      continue
-    }
-    let hideParent = false
-    for (let j = 0; j < feed.length; j++) {
-      const item2 = feed[j]
-      if (
-        item1.replyParent.post.uri === item2.post.uri || // the post itself is there
-        (j < i && item1.replyParent.post.uri === item2.replyParent?.post.uri) // another reply already showed it
-      ) {
-        hideParent = true
-        break
-      }
-    }
-    item1._hideParent = hideParent
-  }
-}
-
-function getSelfReplyUri(item: FeedViewPost): string | undefined {
-  return item.reply?.parent.author.did === item.post.author.did
-    ? item.reply?.parent.uri
-    : undefined
-}
-
-function ts(item: FeedViewPost | FeedItemModel): string {
-  if (item.reason?.indexedAt) {
-    // @ts-ignore need better type checks
-    return item.reason.indexedAt
-  }
-  return item.post.indexedAt
-}
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index eb38509fb..280541b74 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -100,7 +100,7 @@ export class ProfileUiModel {
           if (this.selectedView === Sections.Posts) {
             arr = this.feed.nonReplyFeed
           } else {
-            arr = this.feed.feed.slice()
+            arr = this.feed.slices.slice()
           }
           if (!this.feed.hasMore) {
             arr = arr.concat([ProfileUiModel.END_ITEM])
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 8f57900b5..1edcd55d9 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -16,7 +16,7 @@ import {Text} from '../util/text/Text'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Button} from '../util/forms/Button'
 import {FeedModel} from 'state/models/feed-view'
-import {FeedItem} from './FeedItem'
+import {FeedSlice} from './FeedSlice'
 import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
 import {s} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics'
@@ -61,11 +61,11 @@ export const Feed = observer(function Feed({
       if (feed.isEmpty) {
         feedItems = feedItems.concat([EMPTY_FEED_ITEM])
       } else {
-        feedItems = feedItems.concat(feed.nonReplyFeed)
+        feedItems = feedItems.concat(feed.slices)
       }
     }
     return feedItems
-  }, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.nonReplyFeed])
+  }, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.slices])
 
   // events
   // =
@@ -92,10 +92,6 @@ export const Feed = observer(function Feed({
   // rendering
   // =
 
-  // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
-  //   VirtualizedList: You have a large list that is slow to update - make sure your
-  //   renderItem function renders components that follow React performance best practices
-  //   like PureComponent, shouldComponentUpdate, etc
   const renderItem = React.useCallback(
     ({item}: {item: any}) => {
       if (item === EMPTY_FEED_ITEM) {
@@ -138,7 +134,7 @@ export const Feed = observer(function Feed({
           />
         )
       }
-      return <FeedItem item={item} showFollowBtn={showPostFollowBtn} />
+      return <FeedSlice slice={item} showFollowBtn={showPostFollowBtn} />
     },
     [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation],
   )
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index ec8feb664..35a917591 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -26,11 +26,14 @@ import {useAnalytics} from 'lib/analytics'
 
 export const FeedItem = observer(function ({
   item,
-  showReplyLine,
+  isThreadChild,
+  isThreadParent,
   showFollowBtn,
   ignoreMuteFor,
 }: {
   item: FeedItemModel
+  isThreadChild?: boolean
+  isThreadParent?: boolean
   showReplyLine?: boolean
   showFollowBtn?: boolean
   ignoreMuteFor?: string
@@ -110,10 +113,8 @@ export const FeedItem = observer(function ({
     return <View />
   }
 
-  const isChild =
-    item._isThreadChild || (!item.reason && !item._hideParent && item.reply)
-  const isSmallTop = isChild && item._isThreadChild
-  const isNoTop = isChild && !item._isThreadChild
+  const isSmallTop = isThreadChild
+  const isNoTop = false //isChild && !item._isThreadChild
   const isMuted =
     item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did
   const outerStyles = [
@@ -122,25 +123,18 @@ export const FeedItem = observer(function ({
     {borderColor: pal.colors.border},
     isSmallTop ? styles.outerSmallTop : undefined,
     isNoTop ? styles.outerNoTop : undefined,
-    item._isThreadParent ? styles.outerNoBottom : undefined,
+    isThreadParent ? styles.outerNoBottom : undefined,
   ]
 
   return (
     <PostMutedWrapper isMuted={isMuted}>
-      {isChild && !item._isThreadChild && item.replyParent ? (
-        <FeedItem
-          item={item.replyParent}
-          showReplyLine
-          ignoreMuteFor={ignoreMuteFor}
-        />
-      ) : undefined}
       <Link style={outerStyles} href={itemHref} title={itemTitle} noFeedback>
-        {item._isThreadChild && (
+        {isThreadChild && (
           <View
             style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
           />
         )}
-        {(showReplyLine || item._isThreadParent) && (
+        {isThreadParent && (
           <View
             style={[
               styles.bottomReplyLine,
@@ -199,7 +193,7 @@ export const FeedItem = observer(function ({
               declarationCid={item.post.author.declaration.cid}
               showFollowBtn={showFollowBtn}
             />
-            {!isChild && replyAuthorDid !== '' && (
+            {!isThreadChild && replyAuthorDid !== '' && (
               <View style={[s.flexRow, s.mb2, s.alignCenter]}>
                 <FontAwesomeIcon
                   icon="reply"
@@ -259,7 +253,7 @@ export const FeedItem = observer(function ({
           </View>
         </View>
       </Link>
-      {item._isThreadChildElided ? (
+      {false /*isThreadChildElided*/ ? (
         <Link
           style={[pal.view, styles.viewFullThread]}
           href={itemHref}
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
new file mode 100644
index 000000000..1dba8ac93
--- /dev/null
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -0,0 +1,28 @@
+import React from 'react'
+import {FeedSliceModel} from 'state/models/feed-view'
+import {FeedItem} from './FeedItem'
+
+export function FeedSlice({
+  slice,
+  showFollowBtn,
+  ignoreMuteFor,
+}: {
+  slice: FeedSliceModel
+  showFollowBtn?: boolean
+  ignoreMuteFor?: string
+}) {
+  return (
+    <>
+      {slice.items.map((item, i) => (
+        <FeedItem
+          key={item._reactKey}
+          item={item}
+          isThreadParent={slice.isThreadParentAt(i)}
+          isThreadChild={slice.isThreadChildAt(i)}
+          showFollowBtn={showFollowBtn}
+          ignoreMuteFor={ignoreMuteFor}
+        />
+      ))}
+    </>
+  )
+}
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index a27fa6b82..65f1fef26 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -6,11 +6,11 @@ import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewSelector} from '../com/util/ViewSelector'
 import {CenteredView} from '../com/util/Views'
-import {ProfileUiModel, Sections} from 'state/models/ui/profile'
+import {ProfileUiModel} from 'state/models/ui/profile'
 import {useStores} from 'state/index'
-import {FeedItemModel} from 'state/models/feed-view'
+import {FeedSliceModel} from 'state/models/feed-view'
 import {ProfileHeader} from '../com/profile/ProfileHeader'
-import {FeedItem} from '../com/posts/FeedItem'
+import {FeedSlice} from '../com/posts/FeedSlice'
 import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder'
 import {ErrorScreen} from '../com/util/error/ErrorScreen'
 import {ErrorMessage} from '../com/util/error/ErrorMessage'
@@ -123,8 +123,8 @@ export const ProfileScreen = withAuthRequired(
               style={styles.emptyState}
             />
           )
-        } else if (item instanceof FeedItemModel) {
-          return <FeedItem item={item} ignoreMuteFor={uiState.profile.did} />
+        } else if (item instanceof FeedSliceModel) {
+          return <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
         }
         return <View />
       },