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

zio/stable
Paul Frazee 2023-03-16 15:54:06 -05:00 committed by GitHub
parent 93df983692
commit c50a20d214
7 changed files with 360 additions and 260 deletions

View File

@ -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
}

View File

@ -23,36 +23,27 @@ import {
mergePosts, mergePosts,
} from 'lib/api/build-suggested-posts' } from 'lib/api/build-suggested-posts'
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
let _idCounter = 0 let _idCounter = 0
type FeedViewPostWithThreadMeta = FeedViewPost & {
_isThreadParent?: boolean
_isThreadChildElided?: boolean
_isThreadChild?: boolean
}
export class FeedItemModel { export class FeedItemModel {
// ui state // ui state
_reactKey: string = '' _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 // data
post: PostView post: PostView
postRecord?: AppBskyFeedPost.Record postRecord?: AppBskyFeedPost.Record
reply?: FeedViewPost['reply'] reply?: FeedViewPost['reply']
replyParent?: FeedItemModel
reason?: FeedViewPost['reason'] reason?: FeedViewPost['reason']
richText?: RichText richText?: RichText
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
reactKey: string, reactKey: string,
v: FeedViewPostWithThreadMeta, v: FeedViewPost,
) { ) {
this._reactKey = reactKey this._reactKey = reactKey
this.post = v.post this.post = v.post
@ -78,35 +69,21 @@ export class FeedItemModel {
) )
} }
this.reply = v.reply this.reply = v.reply
if (v.reply?.parent) {
this.replyParent = new FeedItemModel(rootStore, '', {
post: v.reply.parent,
})
}
this.reason = v.reason this.reason = v.reason
this._isThreadParent = v._isThreadParent || false
this._isThreadChild = v._isThreadChild || false
this._isThreadChildElided = v._isThreadChildElided || false
makeAutoObservable(this, {rootStore: false}) makeAutoObservable(this, {rootStore: false})
} }
copy(v: FeedViewPost) { copy(v: FeedViewPost) {
this.post = v.post this.post = v.post
this.reply = v.reply 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 this.reason = v.reason
} }
get _isRenderingAsThread() { copyMetrics(v: FeedViewPost) {
return ( this.post.replyCount = v.post.replyCount
this._isThreadParent || this._isThreadChild || this._isThreadChildElided this.post.repostCount = v.post.repostCount
) this.post.upvoteCount = v.post.upvoteCount
this.post.viewer = v.post.viewer
} }
get reasonRepost(): ReasonRepost | undefined { 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 { export class FeedModel {
// state // state
isLoading = false isLoading = false
@ -203,12 +247,13 @@ export class FeedModel {
hasMore = true hasMore = true
loadMoreCursor: string | undefined loadMoreCursor: string | undefined
pollCursor: string | undefined pollCursor: string | undefined
tuner = new FeedTuner()
// used to linearize async modifications to state // used to linearize async modifications to state
private lock = new AwaitLock() private lock = new AwaitLock()
// data // data
feed: FeedItemModel[] = [] slices: FeedSliceModel[] = []
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
@ -228,7 +273,7 @@ export class FeedModel {
} }
get hasContent() { get hasContent() {
return this.feed.length !== 0 return this.slices.length !== 0
} }
get hasError() { get hasError() {
@ -241,34 +286,21 @@ export class FeedModel {
get nonReplyFeed() { get nonReplyFeed() {
if (this.feedType === 'author') { if (this.feedType === 'author') {
return this.feed.filter(item => { return this.slices.filter(slice => {
const params = this.params as GetAuthorFeed.QueryParams const params = this.params as GetAuthorFeed.QueryParams
const item = slice.rootItem
const isRepost = const isRepost =
item.reply && item?.reasonRepost?.by?.handle === params.author ||
(item?.reasonRepost?.by?.handle === params.author || item?.reasonRepost?.by?.did === params.author
item?.reasonRepost?.by?.did === params.author)
return ( return (
!item.reply || // not a reply !item.reply || // not a reply
isRepost || isRepost || // but allow if it's a repost
((item._isThreadParent || // but allow if it's a thread by the user (slice.isThread && // or a thread by the user
item._isThreadChild) &&
item.reply?.root.author.did === item.post.author.did) 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 { } else {
return this.feed return this.slices
} }
} }
@ -292,7 +324,8 @@ export class FeedModel {
this.hasMore = true this.hasMore = true
this.loadMoreCursor = undefined this.loadMoreCursor = undefined
this.pollCursor = undefined this.pollCursor = undefined
this.feed = [] this.slices = []
this.tuner.reset()
} }
switchFeedType(feedType: 'home' | 'suggested') { switchFeedType(feedType: 'home' | 'suggested') {
@ -314,6 +347,7 @@ export class FeedModel {
await this.lock.acquireAsync() await this.lock.acquireAsync()
try { try {
this.setHasNewLatest(false) this.setHasNewLatest(false)
this.tuner.reset()
this._xLoading(isRefreshing) this._xLoading(isRefreshing)
try { try {
const res = await this._getFeed({limit: PAGE_SIZE}) const res = await this._getFeed({limit: PAGE_SIZE})
@ -401,11 +435,11 @@ export class FeedModel {
update = bundleAsync(async () => { update = bundleAsync(async () => {
await this.lock.acquireAsync() await this.lock.acquireAsync()
try { try {
if (!this.feed.length) { if (!this.slices.length) {
return return
} }
this._xLoading() this._xLoading()
let numToFetch = this.feed.length let numToFetch = this.slices.length
let cursor let cursor
try { try {
do { do {
@ -464,9 +498,9 @@ export class FeedModel {
onPostDeleted(uri: string) { onPostDeleted(uri: string) {
let i let i
do { do {
i = this.feed.findIndex(item => item.post.uri === uri) i = this.slices.findIndex(slice => slice.containsUri(uri))
if (i !== -1) { if (i !== -1) {
this.feed.splice(i, 1) this.slices.splice(i, 1)
} }
} while (i !== -1) } while (i !== -1)
} }
@ -506,27 +540,29 @@ export class FeedModel {
) { ) {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor 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[] = [] const toAppend: FeedSliceModel[] = []
for (const item of reorgedFeed) { for (const slice of slices) {
const itemModel = new FeedItemModel( const sliceModel = new FeedSliceModel(
this.rootStore, this.rootStore,
`item-${_idCounter++}`, `item-${_idCounter++}`,
item, slice,
) )
toAppend.push(itemModel) toAppend.push(sliceModel)
} }
runInAction(() => { runInAction(() => {
if (replace) { if (replace) {
this.feed = toAppend this.slices = toAppend
} else { } 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 this.pollCursor = res.data.feed[0]?.post.uri
const toPrepend: FeedItemModel[] = [] const slices = this.tuner.tune(
for (const item of res.data.feed) { res.data.feed,
if (this.feed.find(item2 => item2.post.uri === item.post.uri)) { this.feedType === 'home'
break // stop here - we've hit a post we already have ? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
} : [],
)
const itemModel = new FeedItemModel( const toPrepend: FeedSliceModel[] = []
for (const slice of slices) {
const itemModel = new FeedSliceModel(
this.rootStore, this.rootStore,
`item-${_idCounter++}`, `item-${_idCounter++}`,
item, slice,
) )
toPrepend.push(itemModel) toPrepend.push(itemModel)
} }
runInAction(() => { runInAction(() => {
this.feed = toPrepend.concat(this.feed) this.slices = toPrepend.concat(this.slices)
}) })
} }
private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
for (const item of res.data.feed) { for (const item of res.data.feed) {
const existingItem = this.feed.find( const existingSlice = this.slices.find(slice =>
// HACK: need to find the reposts' item, so we have to check for that -prf slice.containsUri(item.post.uri),
item2 =>
item.post.uri === item2.post.uri &&
// @ts-ignore todo
item.reason?.by?.did === item2.reason?.by?.did,
) )
if (existingItem) { if (existingSlice) {
existingItem.copy(item) 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
}

View File

@ -100,7 +100,7 @@ export class ProfileUiModel {
if (this.selectedView === Sections.Posts) { if (this.selectedView === Sections.Posts) {
arr = this.feed.nonReplyFeed arr = this.feed.nonReplyFeed
} else { } else {
arr = this.feed.feed.slice() arr = this.feed.slices.slice()
} }
if (!this.feed.hasMore) { if (!this.feed.hasMore) {
arr = arr.concat([ProfileUiModel.END_ITEM]) arr = arr.concat([ProfileUiModel.END_ITEM])

View File

@ -16,7 +16,7 @@ import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {Button} from '../util/forms/Button' import {Button} from '../util/forms/Button'
import {FeedModel} from 'state/models/feed-view' import {FeedModel} from 'state/models/feed-view'
import {FeedItem} from './FeedItem' import {FeedSlice} from './FeedSlice'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {useAnalytics} from 'lib/analytics' import {useAnalytics} from 'lib/analytics'
@ -61,11 +61,11 @@ export const Feed = observer(function Feed({
if (feed.isEmpty) { if (feed.isEmpty) {
feedItems = feedItems.concat([EMPTY_FEED_ITEM]) feedItems = feedItems.concat([EMPTY_FEED_ITEM])
} else { } else {
feedItems = feedItems.concat(feed.nonReplyFeed) feedItems = feedItems.concat(feed.slices)
} }
} }
return feedItems return feedItems
}, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.nonReplyFeed]) }, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.slices])
// events // events
// = // =
@ -92,10 +92,6 @@ export const Feed = observer(function Feed({
// rendering // 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( const renderItem = React.useCallback(
({item}: {item: any}) => { ({item}: {item: any}) => {
if (item === EMPTY_FEED_ITEM) { 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], [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation],
) )

View File

@ -26,11 +26,14 @@ import {useAnalytics} from 'lib/analytics'
export const FeedItem = observer(function ({ export const FeedItem = observer(function ({
item, item,
showReplyLine, isThreadChild,
isThreadParent,
showFollowBtn, showFollowBtn,
ignoreMuteFor, ignoreMuteFor,
}: { }: {
item: FeedItemModel item: FeedItemModel
isThreadChild?: boolean
isThreadParent?: boolean
showReplyLine?: boolean showReplyLine?: boolean
showFollowBtn?: boolean showFollowBtn?: boolean
ignoreMuteFor?: string ignoreMuteFor?: string
@ -110,10 +113,8 @@ export const FeedItem = observer(function ({
return <View /> return <View />
} }
const isChild = const isSmallTop = isThreadChild
item._isThreadChild || (!item.reason && !item._hideParent && item.reply) const isNoTop = false //isChild && !item._isThreadChild
const isSmallTop = isChild && item._isThreadChild
const isNoTop = isChild && !item._isThreadChild
const isMuted = const isMuted =
item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did
const outerStyles = [ const outerStyles = [
@ -122,25 +123,18 @@ export const FeedItem = observer(function ({
{borderColor: pal.colors.border}, {borderColor: pal.colors.border},
isSmallTop ? styles.outerSmallTop : undefined, isSmallTop ? styles.outerSmallTop : undefined,
isNoTop ? styles.outerNoTop : undefined, isNoTop ? styles.outerNoTop : undefined,
item._isThreadParent ? styles.outerNoBottom : undefined, isThreadParent ? styles.outerNoBottom : undefined,
] ]
return ( return (
<PostMutedWrapper isMuted={isMuted}> <PostMutedWrapper isMuted={isMuted}>
{isChild && !item._isThreadChild && item.replyParent ? (
<FeedItem
item={item.replyParent}
showReplyLine
ignoreMuteFor={ignoreMuteFor}
/>
) : undefined}
<Link style={outerStyles} href={itemHref} title={itemTitle} noFeedback> <Link style={outerStyles} href={itemHref} title={itemTitle} noFeedback>
{item._isThreadChild && ( {isThreadChild && (
<View <View
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
/> />
)} )}
{(showReplyLine || item._isThreadParent) && ( {isThreadParent && (
<View <View
style={[ style={[
styles.bottomReplyLine, styles.bottomReplyLine,
@ -199,7 +193,7 @@ export const FeedItem = observer(function ({
declarationCid={item.post.author.declaration.cid} declarationCid={item.post.author.declaration.cid}
showFollowBtn={showFollowBtn} showFollowBtn={showFollowBtn}
/> />
{!isChild && replyAuthorDid !== '' && ( {!isThreadChild && replyAuthorDid !== '' && (
<View style={[s.flexRow, s.mb2, s.alignCenter]}> <View style={[s.flexRow, s.mb2, s.alignCenter]}>
<FontAwesomeIcon <FontAwesomeIcon
icon="reply" icon="reply"
@ -259,7 +253,7 @@ export const FeedItem = observer(function ({
</View> </View>
</View> </View>
</Link> </Link>
{item._isThreadChildElided ? ( {false /*isThreadChildElided*/ ? (
<Link <Link
style={[pal.view, styles.viewFullThread]} style={[pal.view, styles.viewFullThread]}
href={itemHref} href={itemHref}

View File

@ -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}
/>
))}
</>
)
}

View File

@ -6,11 +6,11 @@ import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewSelector} from '../com/util/ViewSelector' import {ViewSelector} from '../com/util/ViewSelector'
import {CenteredView} from '../com/util/Views' 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 {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 {ProfileHeader} from '../com/profile/ProfileHeader'
import {FeedItem} from '../com/posts/FeedItem' import {FeedSlice} from '../com/posts/FeedSlice'
import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder' import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder'
import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {ErrorMessage} from '../com/util/error/ErrorMessage' import {ErrorMessage} from '../com/util/error/ErrorMessage'
@ -123,8 +123,8 @@ export const ProfileScreen = withAuthRequired(
style={styles.emptyState} style={styles.emptyState}
/> />
) )
} else if (item instanceof FeedItemModel) { } else if (item instanceof FeedSliceModel) {
return <FeedItem item={item} ignoreMuteFor={uiState.profile.did} /> return <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
} }
return <View /> return <View />
}, },