Show replies in context of their threads (#4871)
* Don't reconstruct threads from separate posts * Remove post-level dedupe for now * Change repost dedupe condition to look just at length * Delete unused isThread * Delete another isThread field It is now meaningless because there's nothing special about author threads. * Narrow down slice item shape so it does not need reply * Consolidate slice validation criteria in one place * Show replies in context * Make fallback marker work * Remove misleading and now-unused property It was called rootUri but it was actually the leaf URI. Regardless, it's not used anymore. * Add by-thread dedupe to non-author feeds * Add post-level dedupe * Always count from the start This is easier to think about. * Only tuner state need to be untouched on dry run * Account for threads in reply filtering * Remove repost deduping This is already being taken care of by item-level deduping. It's also now wrong and removing too much (since it wasn't filtering for reposts directly). * Calculate rootUri correctly * Apply Following settings to all lists * Don't dedupe intentional reposts by thread * Show reply parent when ambiguous * Explicitly remove orphaned replies from following/lists * Fix thread dedupe to work across pages * Mark grandparent-blocked as orphaned * Guard tuner state change by dryRun * Remove dead code * Don't dedupe feedgen threads * Revert "Apply Following settings to all lists" This reverts commit aff86be6d37b60cc5d0ac38f22c31a4808342cf4. Let's not do this yet and have a bit more discussion. This is a chunky change already. * Reason belongs to a slice, not item * Logically feedContext belongs to the slice * Update comment to reflect latest behaviorzio/stable
parent
18b423396b
commit
74b0318d89
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
|
@ -6,50 +7,118 @@ import {
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {isPostInLanguage} from '../../locale/helpers'
|
import {isPostInLanguage} from '../../locale/helpers'
|
||||||
|
import {FALLBACK_MARKER_POST} from './feed/home'
|
||||||
import {ReasonFeedSource} from './feed/types'
|
import {ReasonFeedSource} from './feed/types'
|
||||||
|
|
||||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||||
|
|
||||||
export type FeedTunerFn = (
|
export type FeedTunerFn = (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
|
dryRun: boolean,
|
||||||
) => FeedViewPostsSlice[]
|
) => FeedViewPostsSlice[]
|
||||||
|
|
||||||
type FeedSliceItem = {
|
type FeedSliceItem = {
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
reply?: AppBskyFeedDefs.ReplyRef
|
record: AppBskyFeedPost.Record
|
||||||
}
|
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
|
isParentBlocked: boolean
|
||||||
function toSliceItem(feedViewPost: FeedViewPost): FeedSliceItem {
|
|
||||||
return {
|
|
||||||
post: feedViewPost.post,
|
|
||||||
reply: feedViewPost.reply,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FeedViewPostsSlice {
|
export class FeedViewPostsSlice {
|
||||||
_reactKey: string
|
_reactKey: string
|
||||||
_feedPost: FeedViewPost
|
_feedPost: FeedViewPost
|
||||||
items: FeedSliceItem[]
|
items: FeedSliceItem[]
|
||||||
|
isIncompleteThread: boolean
|
||||||
|
isFallbackMarker: boolean
|
||||||
|
isOrphan: boolean
|
||||||
|
rootUri: string
|
||||||
|
|
||||||
constructor(feedPost: FeedViewPost) {
|
constructor(feedPost: FeedViewPost) {
|
||||||
|
const {post, reply, reason} = feedPost
|
||||||
|
this.items = []
|
||||||
|
this.isIncompleteThread = false
|
||||||
|
this.isFallbackMarker = false
|
||||||
|
this.isOrphan = false
|
||||||
|
if (AppBskyFeedDefs.isPostView(reply?.root)) {
|
||||||
|
this.rootUri = reply.root.uri
|
||||||
|
} else {
|
||||||
|
this.rootUri = post.uri
|
||||||
|
}
|
||||||
this._feedPost = feedPost
|
this._feedPost = feedPost
|
||||||
this._reactKey = `slice-${feedPost.post.uri}-${
|
this._reactKey = `slice-${post.uri}-${
|
||||||
feedPost.reason?.indexedAt || feedPost.post.indexedAt
|
feedPost.reason?.indexedAt || post.indexedAt
|
||||||
}`
|
}`
|
||||||
this.items = [toSliceItem(feedPost)]
|
if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) {
|
||||||
}
|
this.isFallbackMarker = true
|
||||||
|
return
|
||||||
get uri() {
|
}
|
||||||
return this._feedPost.post.uri
|
if (
|
||||||
}
|
!AppBskyFeedPost.isRecord(post.record) ||
|
||||||
|
!AppBskyFeedPost.validateRecord(post.record).success
|
||||||
get isThread() {
|
) {
|
||||||
return (
|
return
|
||||||
this.items.length > 1 &&
|
}
|
||||||
this.items.every(
|
const parent = reply?.parent
|
||||||
item => item.post.author.did === this.items[0].post.author.did,
|
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
|
||||||
)
|
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
|
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||||
|
parentAuthor = parent.author
|
||||||
|
}
|
||||||
|
this.items.push({
|
||||||
|
post,
|
||||||
|
record: post.record,
|
||||||
|
parentAuthor,
|
||||||
|
isParentBlocked,
|
||||||
|
})
|
||||||
|
if (!reply || reason) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!AppBskyFeedDefs.isPostView(parent) ||
|
||||||
|
!AppBskyFeedPost.isRecord(parent.record) ||
|
||||||
|
!AppBskyFeedPost.validateRecord(parent.record).success
|
||||||
|
) {
|
||||||
|
this.isOrphan = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const grandparentAuthor = reply.grandparentAuthor
|
||||||
|
const isGrandparentBlocked = Boolean(
|
||||||
|
grandparentAuthor?.viewer?.blockedBy ||
|
||||||
|
grandparentAuthor?.viewer?.blocking ||
|
||||||
|
grandparentAuthor?.viewer?.blockingByList,
|
||||||
)
|
)
|
||||||
|
this.items.unshift({
|
||||||
|
post: parent,
|
||||||
|
record: parent.record,
|
||||||
|
parentAuthor: grandparentAuthor,
|
||||||
|
isParentBlocked: isGrandparentBlocked,
|
||||||
|
})
|
||||||
|
if (isGrandparentBlocked) {
|
||||||
|
this.isOrphan = true
|
||||||
|
// Keep going, it might still have a root.
|
||||||
|
}
|
||||||
|
const root = reply.root
|
||||||
|
if (
|
||||||
|
!AppBskyFeedDefs.isPostView(root) ||
|
||||||
|
!AppBskyFeedPost.isRecord(root.record) ||
|
||||||
|
!AppBskyFeedPost.validateRecord(root.record).success
|
||||||
|
) {
|
||||||
|
this.isOrphan = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (root.uri === parent.uri) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.items.unshift({
|
||||||
|
post: root,
|
||||||
|
record: root.record,
|
||||||
|
isParentBlocked: false,
|
||||||
|
parentAuthor: undefined,
|
||||||
|
})
|
||||||
|
if (parent.record.reply?.parent.uri !== root.uri) {
|
||||||
|
this.isIncompleteThread = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get isQuotePost() {
|
get isQuotePost() {
|
||||||
|
@ -90,30 +159,7 @@ export class FeedViewPostsSlice {
|
||||||
return !!this.items.find(item => item.post.uri === uri)
|
return !!this.items.find(item => item.post.uri === uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
isNextInThread(uri: string) {
|
getAllAuthors(): AppBskyActorDefs.ProfileViewBasic[] {
|
||||||
return this.items[this.items.length - 1].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) {
|
|
||||||
const reply = this.items[0].reply
|
|
||||||
if (AppBskyFeedDefs.isPostView(reply.parent)) {
|
|
||||||
this.items.splice(0, 0, {post: reply.parent})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isFollowingAllAuthors(userDid: string) {
|
|
||||||
const feedPost = this._feedPost
|
const feedPost = this._feedPost
|
||||||
const authors = [feedPost.post.author]
|
const authors = [feedPost.post.author]
|
||||||
if (feedPost.reply) {
|
if (feedPost.reply) {
|
||||||
|
@ -127,167 +173,149 @@ export class FeedViewPostsSlice {
|
||||||
authors.push(feedPost.reply.root.author)
|
authors.push(feedPost.reply.root.author)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return authors.every(a => a.did === userDid || a.viewer?.following)
|
return authors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FeedTuner {
|
export class FeedTuner {
|
||||||
seenKeys: Set<string> = new Set()
|
seenKeys: Set<string> = new Set()
|
||||||
seenUris: Set<string> = new Set()
|
seenUris: Set<string> = new Set()
|
||||||
|
seenRootUris: Set<string> = new Set()
|
||||||
|
|
||||||
constructor(public tunerFns: FeedTunerFn[]) {}
|
constructor(public tunerFns: FeedTunerFn[]) {}
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.seenKeys.clear()
|
|
||||||
this.seenUris.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
tune(
|
tune(
|
||||||
feed: FeedViewPost[],
|
feed: FeedViewPost[],
|
||||||
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
|
{dryRun}: {dryRun: boolean} = {
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
maintainOrder: false,
|
|
||||||
},
|
},
|
||||||
): FeedViewPostsSlice[] {
|
): FeedViewPostsSlice[] {
|
||||||
let slices: FeedViewPostsSlice[] = []
|
let slices: FeedViewPostsSlice[] = feed
|
||||||
|
.map(item => new FeedViewPostsSlice(item))
|
||||||
|
.filter(s => s.items.length > 0 || s.isFallbackMarker)
|
||||||
|
|
||||||
// remove posts that are replies, but which don't have the parent
|
// run the custom tuners
|
||||||
// hydrated. this means the parent was either deleted or blocked
|
for (const tunerFn of this.tunerFns) {
|
||||||
feed = feed.filter(item => {
|
slices = tunerFn(this, slices.slice(), dryRun)
|
||||||
if (
|
}
|
||||||
AppBskyFeedPost.isRecord(item.post.record) &&
|
|
||||||
item.post.record.reply &&
|
slices = slices.filter(slice => {
|
||||||
!item.reply
|
if (this.seenKeys.has(slice._reactKey)) {
|
||||||
) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// Some feeds, like Following, dedupe by thread, so you only see the most recent reply.
|
||||||
|
// However, we don't want per-thread dedupe for author feeds (where we need to show every post)
|
||||||
|
// or for feedgens (where we want to let the feed serve multiple replies if it chooses to).
|
||||||
|
// To avoid showing the same context (root and/or parent) more than once, we do last resort
|
||||||
|
// per-post deduplication. It hides already seen posts as long as this doesn't break the thread.
|
||||||
|
for (let i = 0; i < slice.items.length; i++) {
|
||||||
|
const item = slice.items[i]
|
||||||
|
if (this.seenUris.has(item.post.uri)) {
|
||||||
|
if (i === 0) {
|
||||||
|
// Omit contiguous seen leading items.
|
||||||
|
// For example, [A -> B -> C], [A -> D -> E], [A -> D -> F]
|
||||||
|
// would turn into [A -> B -> C], [D -> E], [F].
|
||||||
|
slice.items.splice(0, 1)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
if (i === slice.items.length - 1) {
|
||||||
|
// If the last item in the slice was already seen, omit the whole slice.
|
||||||
|
// This means we'd miss its parents, but the user can "show more" to see them.
|
||||||
|
// For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C]
|
||||||
|
// would get collapsed into [A ... E -> F], with B/C/D considered seen.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!dryRun) {
|
||||||
|
this.seenUris.add(item.post.uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dryRun) {
|
||||||
|
this.seenKeys.add(slice._reactKey)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (maintainOrder) {
|
|
||||||
slices = feed.map(item => new FeedViewPostsSlice(item))
|
|
||||||
} else {
|
|
||||||
// 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 index = slices.findIndex(slice =>
|
|
||||||
slice.isNextInThread(selfReplyUri),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (index !== -1) {
|
|
||||||
const parent = slices[index]
|
|
||||||
|
|
||||||
parent.insert(item)
|
|
||||||
|
|
||||||
// If our slice isn't currently on the top, reinsert it to the top.
|
|
||||||
if (index !== 0) {
|
|
||||||
slices.splice(index, 1)
|
|
||||||
slices.unshift(parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.unshift(new FeedViewPostsSlice(item))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// run the custom tuners
|
|
||||||
for (const tunerFn of this.tunerFns) {
|
|
||||||
slices = tunerFn(this, slices.slice())
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove any items already "seen"
|
|
||||||
const soonToBeSeenUris: Set<string> = new Set()
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
if (!slices[i].isThread && this.seenUris.has(slices[i].uri)) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
} else {
|
|
||||||
for (const item of slices[i].items) {
|
|
||||||
soonToBeSeenUris.add(item.post.uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// turn non-threads with reply parents into threads
|
|
||||||
for (const slice of slices) {
|
|
||||||
if (!slice.isThread && !slice.reason && slice.items[0].reply) {
|
|
||||||
const reply = slice.items[0].reply
|
|
||||||
if (
|
|
||||||
AppBskyFeedDefs.isPostView(reply.parent) &&
|
|
||||||
!this.seenUris.has(reply.parent.uri) &&
|
|
||||||
!soonToBeSeenUris.has(reply.parent.uri)
|
|
||||||
) {
|
|
||||||
const uri = reply.parent.uri
|
|
||||||
slice.flattenReplyParent()
|
|
||||||
soonToBeSeenUris.add(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dryRun) {
|
|
||||||
slices = slices.filter(slice => {
|
|
||||||
if (this.seenKeys.has(slice._reactKey)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for (const item of slice.items) {
|
|
||||||
this.seenUris.add(item.post.uri)
|
|
||||||
}
|
|
||||||
this.seenKeys.add(slice._reactKey)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
static removeReplies(
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
if (slices[i].isReply) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slices
|
|
||||||
}
|
|
||||||
|
|
||||||
static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
if (slices[i].isRepost) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slices
|
|
||||||
}
|
|
||||||
|
|
||||||
static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
if (slices[i].isQuotePost) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slices
|
|
||||||
}
|
|
||||||
|
|
||||||
static dedupReposts(
|
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
): FeedViewPostsSlice[] {
|
_dryRun: boolean,
|
||||||
// remove duplicates caused by reposts
|
) {
|
||||||
for (let i = 0; i < slices.length; i++) {
|
for (let i = 0; i < slices.length; i++) {
|
||||||
const item1 = slices[i]
|
const slice = slices[i]
|
||||||
for (let j = i + 1; j < slices.length; j++) {
|
if (
|
||||||
const item2 = slices[j]
|
slice.isReply &&
|
||||||
if (item2.isThread) {
|
!slice.isRepost &&
|
||||||
// dont dedup items that are rendering in a thread as this can cause rendering errors
|
// This is not perfect but it's close as we can get to
|
||||||
continue
|
// detecting threads without having to peek ahead.
|
||||||
}
|
!areSameAuthor(slice.getAllAuthors())
|
||||||
if (item1.containsUri(item2.items[0].post.uri)) {
|
) {
|
||||||
slices.splice(j, 1)
|
slices.splice(i, 1)
|
||||||
j--
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slices
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeReposts(
|
||||||
|
tuner: FeedTuner,
|
||||||
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < slices.length; i++) {
|
||||||
|
if (slices[i].isRepost) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slices
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeQuotePosts(
|
||||||
|
tuner: FeedTuner,
|
||||||
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < slices.length; i++) {
|
||||||
|
if (slices[i].isQuotePost) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slices
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeOrphans(
|
||||||
|
tuner: FeedTuner,
|
||||||
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < slices.length; i++) {
|
||||||
|
if (slices[i].isOrphan) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slices
|
||||||
|
}
|
||||||
|
|
||||||
|
static dedupThreads(
|
||||||
|
tuner: FeedTuner,
|
||||||
|
slices: FeedViewPostsSlice[],
|
||||||
|
dryRun: boolean,
|
||||||
|
): FeedViewPostsSlice[] {
|
||||||
|
for (let i = 0; i < slices.length; i++) {
|
||||||
|
const rootUri = slices[i].rootUri
|
||||||
|
if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
|
} else {
|
||||||
|
if (!dryRun) {
|
||||||
|
tuner.seenRootUris.add(rootUri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -298,15 +326,17 @@ export class FeedTuner {
|
||||||
return (
|
return (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
for (let i = 0; i < slices.length; i++) {
|
||||||
const slice = slices[i]
|
const slice = slices[i]
|
||||||
if (
|
if (
|
||||||
slice.isReply &&
|
slice.isReply &&
|
||||||
!slice.isRepost &&
|
!slice.isRepost &&
|
||||||
!slice.isFollowingAllAuthors(userDid)
|
!isFollowingAll(slice.getAllAuthors(), userDid)
|
||||||
) {
|
) {
|
||||||
slices.splice(i, 1)
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return slices
|
return slices
|
||||||
|
@ -324,6 +354,7 @@ export class FeedTuner {
|
||||||
return (
|
return (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
const candidateSlices = slices.slice()
|
const candidateSlices = slices.slice()
|
||||||
|
|
||||||
|
@ -332,7 +363,7 @@ export class FeedTuner {
|
||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
for (let i = 0; i < slices.length; i++) {
|
||||||
let hasPreferredLang = false
|
let hasPreferredLang = false
|
||||||
for (const item of slices[i].items) {
|
for (const item of slices[i].items) {
|
||||||
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
||||||
|
@ -358,16 +389,15 @@ export class FeedTuner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelfReplyUri(item: FeedViewPost): string | undefined {
|
function areSameAuthor(authors: AppBskyActorDefs.ProfileViewBasic[]): boolean {
|
||||||
if (item.reply) {
|
const dids = authors.map(a => a.did)
|
||||||
if (
|
const set = new Set(dids)
|
||||||
AppBskyFeedDefs.isPostView(item.reply.parent) &&
|
return set.size === 1
|
||||||
!AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies
|
}
|
||||||
) {
|
|
||||||
return item.reply.parent.author.did === item.post.author.did
|
function isFollowingAll(
|
||||||
? item.reply.parent.uri
|
authors: AppBskyActorDefs.ProfileViewBasic[],
|
||||||
: undefined
|
userDid: string,
|
||||||
}
|
): boolean {
|
||||||
}
|
return authors.every(a => a.did === userDid || a.viewer?.following)
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -193,12 +193,6 @@ class MergeFeedSource {
|
||||||
return this.hasMore && this.queue.length === 0
|
return this.hasMore && this.queue.length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.cursor = undefined
|
|
||||||
this.queue = []
|
|
||||||
this.hasMore = true
|
|
||||||
}
|
|
||||||
|
|
||||||
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
|
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
|
||||||
return this.queue.splice(0, n)
|
return this.queue.splice(0, n)
|
||||||
}
|
}
|
||||||
|
@ -232,11 +226,6 @@ class MergeFeedSource {
|
||||||
class MergeFeedSource_Following extends MergeFeedSource {
|
class MergeFeedSource_Following extends MergeFeedSource {
|
||||||
tuner = new FeedTuner(this.feedTuners)
|
tuner = new FeedTuner(this.feedTuners)
|
||||||
|
|
||||||
reset() {
|
|
||||||
super.reset()
|
|
||||||
this.tuner.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchNext(n: number) {
|
async fetchNext(n: number) {
|
||||||
return this._fetchNextInner(n)
|
return this._fetchNextInner(n)
|
||||||
}
|
}
|
||||||
|
@ -249,7 +238,6 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
||||||
// run the tuner pre-emptively to ensure better mixing
|
// run the tuner pre-emptively to ensure better mixing
|
||||||
const slices = this.tuner.tune(res.data.feed, {
|
const slices = this.tuner.tune(res.data.feed, {
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
maintainOrder: true,
|
|
||||||
})
|
})
|
||||||
res.data.feed = slices.map(slice => slice._feedPost)
|
res.data.feed = slices.map(slice => slice._feedPost)
|
||||||
return res
|
return res
|
||||||
|
|
|
@ -123,7 +123,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
||||||
toString({
|
toString({
|
||||||
item: postItem.uri,
|
item: postItem.uri,
|
||||||
event: 'app.bsky.feed.defs#interactionSeen',
|
event: 'app.bsky.feed.defs#interactionSeen',
|
||||||
feedContext: postItem.feedContext,
|
feedContext: slice.feedContext,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
sendToFeed()
|
sendToFeed()
|
||||||
|
|
|
@ -19,20 +19,15 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (feedDesc.startsWith('feedgen')) {
|
if (feedDesc.startsWith('feedgen')) {
|
||||||
return [
|
return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
|
||||||
FeedTuner.dedupReposts,
|
|
||||||
FeedTuner.preferredLangOnly(langPrefs.contentLanguages),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
if (feedDesc.startsWith('list')) {
|
if (feedDesc.startsWith('list')) {
|
||||||
const feedTuners = []
|
let feedTuners = []
|
||||||
|
|
||||||
if (feedDesc.endsWith('|as_following')) {
|
if (feedDesc.endsWith('|as_following')) {
|
||||||
// Same as Following tuners below, copypaste for now.
|
// Same as Following tuners below, copypaste for now.
|
||||||
|
feedTuners.push(FeedTuner.removeOrphans)
|
||||||
if (preferences?.feedViewPrefs.hideReposts) {
|
if (preferences?.feedViewPrefs.hideReposts) {
|
||||||
feedTuners.push(FeedTuner.removeReposts)
|
feedTuners.push(FeedTuner.removeReposts)
|
||||||
} else {
|
|
||||||
feedTuners.push(FeedTuner.dedupReposts)
|
|
||||||
}
|
}
|
||||||
if (preferences?.feedViewPrefs.hideReplies) {
|
if (preferences?.feedViewPrefs.hideReplies) {
|
||||||
feedTuners.push(FeedTuner.removeReplies)
|
feedTuners.push(FeedTuner.removeReplies)
|
||||||
|
@ -46,18 +41,15 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||||
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
||||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
feedTuners.push(FeedTuner.removeQuotePosts)
|
||||||
}
|
}
|
||||||
} else {
|
feedTuners.push(FeedTuner.dedupThreads)
|
||||||
feedTuners.push(FeedTuner.dedupReposts)
|
|
||||||
}
|
}
|
||||||
return feedTuners
|
return feedTuners
|
||||||
}
|
}
|
||||||
if (feedDesc === 'following') {
|
if (feedDesc === 'following') {
|
||||||
const feedTuners = []
|
const feedTuners = [FeedTuner.removeOrphans]
|
||||||
|
|
||||||
if (preferences?.feedViewPrefs.hideReposts) {
|
if (preferences?.feedViewPrefs.hideReposts) {
|
||||||
feedTuners.push(FeedTuner.removeReposts)
|
feedTuners.push(FeedTuner.removeReposts)
|
||||||
} else {
|
|
||||||
feedTuners.push(FeedTuner.dedupReposts)
|
|
||||||
}
|
}
|
||||||
if (preferences?.feedViewPrefs.hideReplies) {
|
if (preferences?.feedViewPrefs.hideReplies) {
|
||||||
feedTuners.push(FeedTuner.removeReplies)
|
feedTuners.push(FeedTuner.removeReplies)
|
||||||
|
@ -71,6 +63,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||||
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
||||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
feedTuners.push(FeedTuner.removeQuotePosts)
|
||||||
}
|
}
|
||||||
|
feedTuners.push(FeedTuner.dedupThreads)
|
||||||
|
|
||||||
return feedTuners
|
return feedTuners
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,11 +77,6 @@ export interface FeedPostSliceItem {
|
||||||
uri: string
|
uri: string
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
record: AppBskyFeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
reason?:
|
|
||||||
| AppBskyFeedDefs.ReasonRepost
|
|
||||||
| ReasonFeedSource
|
|
||||||
| {[k: string]: unknown; $type: string}
|
|
||||||
feedContext: string | undefined
|
|
||||||
moderation: ModerationDecision
|
moderation: ModerationDecision
|
||||||
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
||||||
isParentBlocked?: boolean
|
isParentBlocked?: boolean
|
||||||
|
@ -90,9 +85,14 @@ export interface FeedPostSliceItem {
|
||||||
export interface FeedPostSlice {
|
export interface FeedPostSlice {
|
||||||
_isFeedPostSlice: boolean
|
_isFeedPostSlice: boolean
|
||||||
_reactKey: string
|
_reactKey: string
|
||||||
rootUri: string
|
|
||||||
isThread: boolean
|
|
||||||
items: FeedPostSliceItem[]
|
items: FeedPostSliceItem[]
|
||||||
|
isIncompleteThread: boolean
|
||||||
|
isFallbackMarker: boolean
|
||||||
|
feedContext: string | undefined
|
||||||
|
reason?:
|
||||||
|
| AppBskyFeedDefs.ReasonRepost
|
||||||
|
| ReasonFeedSource
|
||||||
|
| {[k: string]: unknown; $type: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedPageUnselected {
|
export interface FeedPageUnselected {
|
||||||
|
@ -313,53 +313,22 @@ export function usePostFeedQuery(
|
||||||
const feedPostSlice: FeedPostSlice = {
|
const feedPostSlice: FeedPostSlice = {
|
||||||
_reactKey: slice._reactKey,
|
_reactKey: slice._reactKey,
|
||||||
_isFeedPostSlice: true,
|
_isFeedPostSlice: true,
|
||||||
rootUri: slice.uri,
|
isIncompleteThread: slice.isIncompleteThread,
|
||||||
isThread:
|
isFallbackMarker: slice.isFallbackMarker,
|
||||||
slice.items.length > 1 &&
|
feedContext: slice.feedContext,
|
||||||
slice.items.every(
|
reason: slice.reason,
|
||||||
item =>
|
items: slice.items.map((item, i) => {
|
||||||
item.post.author.did ===
|
const feedPostSliceItem: FeedPostSliceItem = {
|
||||||
slice.items[0].post.author.did,
|
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||||
),
|
uri: item.post.uri,
|
||||||
items: slice.items
|
post: item.post,
|
||||||
.map((item, i) => {
|
record: item.record,
|
||||||
if (
|
moderation: moderations[i],
|
||||||
AppBskyFeedPost.isRecord(item.post.record) &&
|
parentAuthor: item.parentAuthor,
|
||||||
AppBskyFeedPost.validateRecord(item.post.record)
|
isParentBlocked: item.isParentBlocked,
|
||||||
.success
|
}
|
||||||
) {
|
return feedPostSliceItem
|
||||||
const parent = item.reply?.parent
|
}),
|
||||||
let parentAuthor:
|
|
||||||
| AppBskyActorDefs.ProfileViewBasic
|
|
||||||
| undefined
|
|
||||||
if (AppBskyFeedDefs.isPostView(parent)) {
|
|
||||||
parentAuthor = parent.author
|
|
||||||
}
|
|
||||||
if (!parentAuthor) {
|
|
||||||
parentAuthor =
|
|
||||||
slice.items[i + 1]?.reply?.grandparentAuthor
|
|
||||||
}
|
|
||||||
const replyRef = item.reply
|
|
||||||
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
|
|
||||||
replyRef?.parent,
|
|
||||||
)
|
|
||||||
|
|
||||||
const feedPostSliceItem: FeedPostSliceItem = {
|
|
||||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
|
||||||
uri: item.post.uri,
|
|
||||||
post: item.post,
|
|
||||||
record: item.post.record,
|
|
||||||
reason: slice.reason,
|
|
||||||
feedContext: slice.feedContext,
|
|
||||||
moderation: moderations[i],
|
|
||||||
parentAuthor,
|
|
||||||
isParentBlocked,
|
|
||||||
}
|
|
||||||
return feedPostSliceItem
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
.filter(n => !!n),
|
|
||||||
}
|
}
|
||||||
return feedPostSlice
|
return feedPostSlice
|
||||||
})
|
})
|
||||||
|
@ -442,7 +411,6 @@ export async function pollLatest(page: FeedPage | undefined) {
|
||||||
if (post) {
|
if (post) {
|
||||||
const slices = page.tuner.tune([post], {
|
const slices = page.tuner.tune([post], {
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
maintainOrder: true,
|
|
||||||
})
|
})
|
||||||
if (slices[0]) {
|
if (slices[0]) {
|
||||||
return true
|
return true
|
||||||
|
|
|
@ -14,7 +14,6 @@ import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home'
|
|
||||||
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
|
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
|
||||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
@ -472,7 +471,7 @@ let Feed = ({
|
||||||
} else if (item.type === progressGuideInterstitialType) {
|
} else if (item.type === progressGuideInterstitialType) {
|
||||||
return <ProgressGuide />
|
return <ProgressGuide />
|
||||||
} else if (item.type === 'slice') {
|
} else if (item.type === 'slice') {
|
||||||
if (item.slice.rootUri === FALLBACK_MARKER_POST.post.uri) {
|
if (item.slice.isFallbackMarker) {
|
||||||
// HACK
|
// HACK
|
||||||
// tell the user we fell back to discover
|
// tell the user we fell back to discover
|
||||||
// see home.ts (feed api) for more info
|
// see home.ts (feed api) for more info
|
||||||
|
|
|
@ -345,11 +345,9 @@ let FeedItemInner = ({
|
||||||
postHref={href}
|
postHref={href}
|
||||||
onOpenAuthor={onOpenAuthor}
|
onOpenAuthor={onOpenAuthor}
|
||||||
/>
|
/>
|
||||||
{!isThreadChild &&
|
{showReplyTo && (parentAuthor || isParentBlocked) && (
|
||||||
showReplyTo &&
|
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
|
||||||
(parentAuthor || isParentBlocked) && (
|
)}
|
||||||
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
|
|
||||||
)}
|
|
||||||
<LabelsOnMyPost post={post} />
|
<LabelsOnMyPost post={post} />
|
||||||
<PostContent
|
<PostContent
|
||||||
moderation={moderation}
|
moderation={moderation}
|
||||||
|
|
|
@ -18,7 +18,7 @@ let FeedSlice = ({
|
||||||
slice: FeedPostSlice
|
slice: FeedPostSlice
|
||||||
hideTopBorder?: boolean
|
hideTopBorder?: boolean
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
if (slice.isThread && slice.items.length > 3) {
|
if (slice.isIncompleteThread && slice.items.length >= 3) {
|
||||||
const beforeLast = slice.items.length - 2
|
const beforeLast = slice.items.length - 2
|
||||||
const last = slice.items.length - 1
|
const last = slice.items.length - 1
|
||||||
return (
|
return (
|
||||||
|
@ -27,25 +27,28 @@ let FeedSlice = ({
|
||||||
key={slice.items[0]._reactKey}
|
key={slice.items[0]._reactKey}
|
||||||
post={slice.items[0].post}
|
post={slice.items[0].post}
|
||||||
record={slice.items[0].record}
|
record={slice.items[0].record}
|
||||||
reason={slice.items[0].reason}
|
reason={slice.reason}
|
||||||
feedContext={slice.items[0].feedContext}
|
feedContext={slice.feedContext}
|
||||||
parentAuthor={slice.items[0].parentAuthor}
|
parentAuthor={slice.items[0].parentAuthor}
|
||||||
showReplyTo={true}
|
showReplyTo={false}
|
||||||
moderation={slice.items[0].moderation}
|
moderation={slice.items[0].moderation}
|
||||||
isThreadParent={isThreadParentAt(slice.items, 0)}
|
isThreadParent={isThreadParentAt(slice.items, 0)}
|
||||||
isThreadChild={isThreadChildAt(slice.items, 0)}
|
isThreadChild={isThreadChildAt(slice.items, 0)}
|
||||||
hideTopBorder={hideTopBorder}
|
hideTopBorder={hideTopBorder}
|
||||||
isParentBlocked={slice.items[0].isParentBlocked}
|
isParentBlocked={slice.items[0].isParentBlocked}
|
||||||
/>
|
/>
|
||||||
<ViewFullThread slice={slice} />
|
<ViewFullThread uri={slice.items[0].uri} />
|
||||||
<FeedItem
|
<FeedItem
|
||||||
key={slice.items[beforeLast]._reactKey}
|
key={slice.items[beforeLast]._reactKey}
|
||||||
post={slice.items[beforeLast].post}
|
post={slice.items[beforeLast].post}
|
||||||
record={slice.items[beforeLast].record}
|
record={slice.items[beforeLast].record}
|
||||||
reason={slice.items[beforeLast].reason}
|
reason={undefined}
|
||||||
feedContext={slice.items[beforeLast].feedContext}
|
feedContext={slice.feedContext}
|
||||||
parentAuthor={slice.items[beforeLast].parentAuthor}
|
parentAuthor={slice.items[beforeLast].parentAuthor}
|
||||||
showReplyTo={false}
|
showReplyTo={
|
||||||
|
slice.items[beforeLast].parentAuthor?.did !==
|
||||||
|
slice.items[beforeLast].post.author.did
|
||||||
|
}
|
||||||
moderation={slice.items[beforeLast].moderation}
|
moderation={slice.items[beforeLast].moderation}
|
||||||
isThreadParent={isThreadParentAt(slice.items, beforeLast)}
|
isThreadParent={isThreadParentAt(slice.items, beforeLast)}
|
||||||
isThreadChild={isThreadChildAt(slice.items, beforeLast)}
|
isThreadChild={isThreadChildAt(slice.items, beforeLast)}
|
||||||
|
@ -55,8 +58,8 @@ let FeedSlice = ({
|
||||||
key={slice.items[last]._reactKey}
|
key={slice.items[last]._reactKey}
|
||||||
post={slice.items[last].post}
|
post={slice.items[last].post}
|
||||||
record={slice.items[last].record}
|
record={slice.items[last].record}
|
||||||
reason={slice.items[last].reason}
|
reason={undefined}
|
||||||
feedContext={slice.items[last].feedContext}
|
feedContext={slice.feedContext}
|
||||||
parentAuthor={slice.items[last].parentAuthor}
|
parentAuthor={slice.items[last].parentAuthor}
|
||||||
showReplyTo={false}
|
showReplyTo={false}
|
||||||
moderation={slice.items[last].moderation}
|
moderation={slice.items[last].moderation}
|
||||||
|
@ -76,8 +79,8 @@ let FeedSlice = ({
|
||||||
key={item._reactKey}
|
key={item._reactKey}
|
||||||
post={slice.items[i].post}
|
post={slice.items[i].post}
|
||||||
record={slice.items[i].record}
|
record={slice.items[i].record}
|
||||||
reason={slice.items[i].reason}
|
reason={i === 0 ? slice.reason : undefined}
|
||||||
feedContext={slice.items[i].feedContext}
|
feedContext={slice.feedContext}
|
||||||
moderation={slice.items[i].moderation}
|
moderation={slice.items[i].moderation}
|
||||||
parentAuthor={slice.items[i].parentAuthor}
|
parentAuthor={slice.items[i].parentAuthor}
|
||||||
showReplyTo={i === 0}
|
showReplyTo={i === 0}
|
||||||
|
@ -96,12 +99,12 @@ let FeedSlice = ({
|
||||||
FeedSlice = memo(FeedSlice)
|
FeedSlice = memo(FeedSlice)
|
||||||
export {FeedSlice}
|
export {FeedSlice}
|
||||||
|
|
||||||
function ViewFullThread({slice}: {slice: FeedPostSlice}) {
|
function ViewFullThread({uri}: {uri: string}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const itemHref = React.useMemo(() => {
|
const itemHref = React.useMemo(() => {
|
||||||
const urip = new AtUri(slice.rootUri)
|
const urip = new AtUri(uri)
|
||||||
return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey)
|
return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey)
|
||||||
}, [slice.rootUri])
|
}, [uri])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link style={[styles.viewFullThread]} href={itemHref} asAnchor noFeedback>
|
<Link style={[styles.viewFullThread]} href={itemHref} asAnchor noFeedback>
|
||||||
|
|
Loading…
Reference in New Issue