diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts new file mode 100644 index 00000000..00938be9 --- /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 = 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 e27712d1..42b753b2 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 eb38509f..280541b7 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 8f57900b..1edcd55d 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 + return }, [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation], ) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index ec8feb66..35a91759 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 } - 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 ( - {isChild && !item._isThreadChild && item.replyParent ? ( - - ) : undefined} - {item._isThreadChild && ( + {isThreadChild && ( )} - {(showReplyLine || item._isThreadParent) && ( + {isThreadParent && ( - {!isChild && replyAuthorDid !== '' && ( + {!isThreadChild && replyAuthorDid !== '' && ( - {item._isThreadChildElided ? ( + {false /*isThreadChildElided*/ ? ( + {slice.items.map((item, i) => ( + + ))} + + ) +} diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index a27fa6b8..65f1fef2 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 + } else if (item instanceof FeedSliceModel) { + return } return },