Refactor feed manipulation and rendering to be more robust (#297)
parent
93df983692
commit
c50a20d214
|
@ -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
|
||||||
|
}
|
|
@ -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 &&
|
if (existingSlice) {
|
||||||
// @ts-ignore todo
|
const existingItem = existingSlice.items.find(
|
||||||
item.reason?.by?.did === item2.reason?.by?.did,
|
item2 => item2.post.uri === item.post.uri,
|
||||||
)
|
)
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
existingItem.copy(item)
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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],
|
||||||
)
|
)
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 />
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in New Issue