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 behavior
zio/stable
dan 2024-08-05 20:51:41 +01:00 committed by GitHub
parent 18b423396b
commit 74b0318d89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 279 additions and 300 deletions

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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