Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

dan and committed by
GitHub
74b0318d 18b42339

+254 -275
+202 -172
src/lib/api/feed-manip.ts
··· 1 1 import { 2 + AppBskyActorDefs, 2 3 AppBskyEmbedRecord, 3 4 AppBskyEmbedRecordWithMedia, 4 5 AppBskyFeedDefs, ··· 6 7 } from '@atproto/api' 7 8 8 9 import {isPostInLanguage} from '../../locale/helpers' 10 + import {FALLBACK_MARKER_POST} from './feed/home' 9 11 import {ReasonFeedSource} from './feed/types' 12 + 10 13 type FeedViewPost = AppBskyFeedDefs.FeedViewPost 11 14 12 15 export type FeedTunerFn = ( 13 16 tuner: FeedTuner, 14 17 slices: FeedViewPostsSlice[], 18 + dryRun: boolean, 15 19 ) => FeedViewPostsSlice[] 16 20 17 21 type FeedSliceItem = { 18 22 post: AppBskyFeedDefs.PostView 19 - reply?: AppBskyFeedDefs.ReplyRef 20 - } 21 - 22 - function toSliceItem(feedViewPost: FeedViewPost): FeedSliceItem { 23 - return { 24 - post: feedViewPost.post, 25 - reply: feedViewPost.reply, 26 - } 23 + record: AppBskyFeedPost.Record 24 + parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 25 + isParentBlocked: boolean 27 26 } 28 27 29 28 export class FeedViewPostsSlice { 30 29 _reactKey: string 31 30 _feedPost: FeedViewPost 32 31 items: FeedSliceItem[] 32 + isIncompleteThread: boolean 33 + isFallbackMarker: boolean 34 + isOrphan: boolean 35 + rootUri: string 33 36 34 37 constructor(feedPost: FeedViewPost) { 38 + const {post, reply, reason} = feedPost 39 + this.items = [] 40 + this.isIncompleteThread = false 41 + this.isFallbackMarker = false 42 + this.isOrphan = false 43 + if (AppBskyFeedDefs.isPostView(reply?.root)) { 44 + this.rootUri = reply.root.uri 45 + } else { 46 + this.rootUri = post.uri 47 + } 35 48 this._feedPost = feedPost 36 - this._reactKey = `slice-${feedPost.post.uri}-${ 37 - feedPost.reason?.indexedAt || feedPost.post.indexedAt 49 + this._reactKey = `slice-${post.uri}-${ 50 + feedPost.reason?.indexedAt || post.indexedAt 38 51 }` 39 - this.items = [toSliceItem(feedPost)] 40 - } 41 - 42 - get uri() { 43 - return this._feedPost.post.uri 44 - } 45 - 46 - get isThread() { 47 - return ( 48 - this.items.length > 1 && 49 - this.items.every( 50 - item => item.post.author.did === this.items[0].post.author.did, 51 - ) 52 + if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) { 53 + this.isFallbackMarker = true 54 + return 55 + } 56 + if ( 57 + !AppBskyFeedPost.isRecord(post.record) || 58 + !AppBskyFeedPost.validateRecord(post.record).success 59 + ) { 60 + return 61 + } 62 + const parent = reply?.parent 63 + const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent) 64 + let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 65 + if (AppBskyFeedDefs.isPostView(parent)) { 66 + parentAuthor = parent.author 67 + } 68 + this.items.push({ 69 + post, 70 + record: post.record, 71 + parentAuthor, 72 + isParentBlocked, 73 + }) 74 + if (!reply || reason) { 75 + return 76 + } 77 + if ( 78 + !AppBskyFeedDefs.isPostView(parent) || 79 + !AppBskyFeedPost.isRecord(parent.record) || 80 + !AppBskyFeedPost.validateRecord(parent.record).success 81 + ) { 82 + this.isOrphan = true 83 + return 84 + } 85 + const grandparentAuthor = reply.grandparentAuthor 86 + const isGrandparentBlocked = Boolean( 87 + grandparentAuthor?.viewer?.blockedBy || 88 + grandparentAuthor?.viewer?.blocking || 89 + grandparentAuthor?.viewer?.blockingByList, 52 90 ) 91 + this.items.unshift({ 92 + post: parent, 93 + record: parent.record, 94 + parentAuthor: grandparentAuthor, 95 + isParentBlocked: isGrandparentBlocked, 96 + }) 97 + if (isGrandparentBlocked) { 98 + this.isOrphan = true 99 + // Keep going, it might still have a root. 100 + } 101 + const root = reply.root 102 + if ( 103 + !AppBskyFeedDefs.isPostView(root) || 104 + !AppBskyFeedPost.isRecord(root.record) || 105 + !AppBskyFeedPost.validateRecord(root.record).success 106 + ) { 107 + this.isOrphan = true 108 + return 109 + } 110 + if (root.uri === parent.uri) { 111 + return 112 + } 113 + this.items.unshift({ 114 + post: root, 115 + record: root.record, 116 + isParentBlocked: false, 117 + parentAuthor: undefined, 118 + }) 119 + if (parent.record.reply?.parent.uri !== root.uri) { 120 + this.isIncompleteThread = true 121 + } 53 122 } 54 123 55 124 get isQuotePost() { ··· 90 159 return !!this.items.find(item => item.post.uri === uri) 91 160 } 92 161 93 - isNextInThread(uri: string) { 94 - return this.items[this.items.length - 1].post.uri === uri 95 - } 96 - 97 - insert(item: FeedViewPost) { 98 - const selfReplyUri = getSelfReplyUri(item) 99 - const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri) 100 - if (i !== -1) { 101 - this.items.splice(i + 1, 0, item) 102 - } else { 103 - this.items.push(item) 104 - } 105 - } 106 - 107 - flattenReplyParent() { 108 - if (this.items[0].reply) { 109 - const reply = this.items[0].reply 110 - if (AppBskyFeedDefs.isPostView(reply.parent)) { 111 - this.items.splice(0, 0, {post: reply.parent}) 112 - } 113 - } 114 - } 115 - 116 - isFollowingAllAuthors(userDid: string) { 162 + getAllAuthors(): AppBskyActorDefs.ProfileViewBasic[] { 117 163 const feedPost = this._feedPost 118 164 const authors = [feedPost.post.author] 119 165 if (feedPost.reply) { ··· 127 173 authors.push(feedPost.reply.root.author) 128 174 } 129 175 } 130 - return authors.every(a => a.did === userDid || a.viewer?.following) 176 + return authors 131 177 } 132 178 } 133 179 134 180 export class FeedTuner { 135 181 seenKeys: Set<string> = new Set() 136 182 seenUris: Set<string> = new Set() 183 + seenRootUris: Set<string> = new Set() 137 184 138 185 constructor(public tunerFns: FeedTunerFn[]) {} 139 186 140 - reset() { 141 - this.seenKeys.clear() 142 - this.seenUris.clear() 143 - } 144 - 145 187 tune( 146 188 feed: FeedViewPost[], 147 - {dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = { 189 + {dryRun}: {dryRun: boolean} = { 148 190 dryRun: false, 149 - maintainOrder: false, 150 191 }, 151 192 ): FeedViewPostsSlice[] { 152 - let slices: FeedViewPostsSlice[] = [] 153 - 154 - // remove posts that are replies, but which don't have the parent 155 - // hydrated. this means the parent was either deleted or blocked 156 - feed = feed.filter(item => { 157 - if ( 158 - AppBskyFeedPost.isRecord(item.post.record) && 159 - item.post.record.reply && 160 - !item.reply 161 - ) { 162 - return false 163 - } 164 - return true 165 - }) 166 - 167 - if (maintainOrder) { 168 - slices = feed.map(item => new FeedViewPostsSlice(item)) 169 - } else { 170 - // arrange the posts into thread slices 171 - for (let i = feed.length - 1; i >= 0; i--) { 172 - const item = feed[i] 173 - 174 - const selfReplyUri = getSelfReplyUri(item) 175 - if (selfReplyUri) { 176 - const index = slices.findIndex(slice => 177 - slice.isNextInThread(selfReplyUri), 178 - ) 179 - 180 - if (index !== -1) { 181 - const parent = slices[index] 182 - 183 - parent.insert(item) 184 - 185 - // If our slice isn't currently on the top, reinsert it to the top. 186 - if (index !== 0) { 187 - slices.splice(index, 1) 188 - slices.unshift(parent) 189 - } 190 - 191 - continue 192 - } 193 - } 194 - 195 - slices.unshift(new FeedViewPostsSlice(item)) 196 - } 197 - } 193 + let slices: FeedViewPostsSlice[] = feed 194 + .map(item => new FeedViewPostsSlice(item)) 195 + .filter(s => s.items.length > 0 || s.isFallbackMarker) 198 196 199 197 // run the custom tuners 200 198 for (const tunerFn of this.tunerFns) { 201 - slices = tunerFn(this, slices.slice()) 199 + slices = tunerFn(this, slices.slice(), dryRun) 202 200 } 203 201 204 - // remove any items already "seen" 205 - const soonToBeSeenUris: Set<string> = new Set() 206 - for (let i = slices.length - 1; i >= 0; i--) { 207 - if (!slices[i].isThread && this.seenUris.has(slices[i].uri)) { 208 - slices.splice(i, 1) 209 - } else { 210 - for (const item of slices[i].items) { 211 - soonToBeSeenUris.add(item.post.uri) 212 - } 202 + slices = slices.filter(slice => { 203 + if (this.seenKeys.has(slice._reactKey)) { 204 + return false 213 205 } 214 - } 215 - 216 - // turn non-threads with reply parents into threads 217 - for (const slice of slices) { 218 - if (!slice.isThread && !slice.reason && slice.items[0].reply) { 219 - const reply = slice.items[0].reply 220 - if ( 221 - AppBskyFeedDefs.isPostView(reply.parent) && 222 - !this.seenUris.has(reply.parent.uri) && 223 - !soonToBeSeenUris.has(reply.parent.uri) 224 - ) { 225 - const uri = reply.parent.uri 226 - slice.flattenReplyParent() 227 - soonToBeSeenUris.add(uri) 206 + // Some feeds, like Following, dedupe by thread, so you only see the most recent reply. 207 + // However, we don't want per-thread dedupe for author feeds (where we need to show every post) 208 + // or for feedgens (where we want to let the feed serve multiple replies if it chooses to). 209 + // To avoid showing the same context (root and/or parent) more than once, we do last resort 210 + // per-post deduplication. It hides already seen posts as long as this doesn't break the thread. 211 + for (let i = 0; i < slice.items.length; i++) { 212 + const item = slice.items[i] 213 + if (this.seenUris.has(item.post.uri)) { 214 + if (i === 0) { 215 + // Omit contiguous seen leading items. 216 + // For example, [A -> B -> C], [A -> D -> E], [A -> D -> F] 217 + // would turn into [A -> B -> C], [D -> E], [F]. 218 + slice.items.splice(0, 1) 219 + i-- 220 + } 221 + if (i === slice.items.length - 1) { 222 + // If the last item in the slice was already seen, omit the whole slice. 223 + // This means we'd miss its parents, but the user can "show more" to see them. 224 + // For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C] 225 + // would get collapsed into [A ... E -> F], with B/C/D considered seen. 226 + return false 227 + } 228 + } else { 229 + if (!dryRun) { 230 + this.seenUris.add(item.post.uri) 231 + } 228 232 } 229 233 } 230 - } 231 - 232 - if (!dryRun) { 233 - slices = slices.filter(slice => { 234 - if (this.seenKeys.has(slice._reactKey)) { 235 - return false 236 - } 237 - for (const item of slice.items) { 238 - this.seenUris.add(item.post.uri) 239 - } 234 + if (!dryRun) { 240 235 this.seenKeys.add(slice._reactKey) 241 - return true 242 - }) 243 - } 236 + } 237 + return true 238 + }) 244 239 245 240 return slices 246 241 } 247 242 248 - static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { 249 - for (let i = slices.length - 1; i >= 0; i--) { 250 - if (slices[i].isReply) { 243 + static removeReplies( 244 + tuner: FeedTuner, 245 + slices: FeedViewPostsSlice[], 246 + _dryRun: boolean, 247 + ) { 248 + for (let i = 0; i < slices.length; i++) { 249 + const slice = slices[i] 250 + if ( 251 + slice.isReply && 252 + !slice.isRepost && 253 + // This is not perfect but it's close as we can get to 254 + // detecting threads without having to peek ahead. 255 + !areSameAuthor(slice.getAllAuthors()) 256 + ) { 251 257 slices.splice(i, 1) 258 + i-- 252 259 } 253 260 } 254 261 return slices 255 262 } 256 263 257 - static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { 258 - for (let i = slices.length - 1; i >= 0; i--) { 264 + static removeReposts( 265 + tuner: FeedTuner, 266 + slices: FeedViewPostsSlice[], 267 + _dryRun: boolean, 268 + ) { 269 + for (let i = 0; i < slices.length; i++) { 259 270 if (slices[i].isRepost) { 260 271 slices.splice(i, 1) 272 + i-- 261 273 } 262 274 } 263 275 return slices 264 276 } 265 277 266 - static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { 267 - for (let i = slices.length - 1; i >= 0; i--) { 278 + static removeQuotePosts( 279 + tuner: FeedTuner, 280 + slices: FeedViewPostsSlice[], 281 + _dryRun: boolean, 282 + ) { 283 + for (let i = 0; i < slices.length; i++) { 268 284 if (slices[i].isQuotePost) { 269 285 slices.splice(i, 1) 286 + i-- 270 287 } 271 288 } 272 289 return slices 273 290 } 274 291 275 - static dedupReposts( 292 + static removeOrphans( 293 + tuner: FeedTuner, 294 + slices: FeedViewPostsSlice[], 295 + _dryRun: boolean, 296 + ) { 297 + for (let i = 0; i < slices.length; i++) { 298 + if (slices[i].isOrphan) { 299 + slices.splice(i, 1) 300 + i-- 301 + } 302 + } 303 + return slices 304 + } 305 + 306 + static dedupThreads( 276 307 tuner: FeedTuner, 277 308 slices: FeedViewPostsSlice[], 309 + dryRun: boolean, 278 310 ): FeedViewPostsSlice[] { 279 - // remove duplicates caused by reposts 280 311 for (let i = 0; i < slices.length; i++) { 281 - const item1 = slices[i] 282 - for (let j = i + 1; j < slices.length; j++) { 283 - const item2 = slices[j] 284 - if (item2.isThread) { 285 - // dont dedup items that are rendering in a thread as this can cause rendering errors 286 - continue 287 - } 288 - if (item1.containsUri(item2.items[0].post.uri)) { 289 - slices.splice(j, 1) 290 - j-- 312 + const rootUri = slices[i].rootUri 313 + if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) { 314 + slices.splice(i, 1) 315 + i-- 316 + } else { 317 + if (!dryRun) { 318 + tuner.seenRootUris.add(rootUri) 291 319 } 292 320 } 293 321 } ··· 298 326 return ( 299 327 tuner: FeedTuner, 300 328 slices: FeedViewPostsSlice[], 329 + _dryRun: boolean, 301 330 ): FeedViewPostsSlice[] => { 302 - for (let i = slices.length - 1; i >= 0; i--) { 331 + for (let i = 0; i < slices.length; i++) { 303 332 const slice = slices[i] 304 333 if ( 305 334 slice.isReply && 306 335 !slice.isRepost && 307 - !slice.isFollowingAllAuthors(userDid) 336 + !isFollowingAll(slice.getAllAuthors(), userDid) 308 337 ) { 309 338 slices.splice(i, 1) 339 + i-- 310 340 } 311 341 } 312 342 return slices ··· 324 354 return ( 325 355 tuner: FeedTuner, 326 356 slices: FeedViewPostsSlice[], 357 + _dryRun: boolean, 327 358 ): FeedViewPostsSlice[] => { 328 359 const candidateSlices = slices.slice() 329 360 ··· 332 363 return slices 333 364 } 334 365 335 - for (let i = slices.length - 1; i >= 0; i--) { 366 + for (let i = 0; i < slices.length; i++) { 336 367 let hasPreferredLang = false 337 368 for (const item of slices[i].items) { 338 369 if (isPostInLanguage(item.post, preferredLangsCode2)) { ··· 358 389 } 359 390 } 360 391 361 - function getSelfReplyUri(item: FeedViewPost): string | undefined { 362 - if (item.reply) { 363 - if ( 364 - AppBskyFeedDefs.isPostView(item.reply.parent) && 365 - !AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies 366 - ) { 367 - return item.reply.parent.author.did === item.post.author.did 368 - ? item.reply.parent.uri 369 - : undefined 370 - } 371 - } 372 - return undefined 392 + function areSameAuthor(authors: AppBskyActorDefs.ProfileViewBasic[]): boolean { 393 + const dids = authors.map(a => a.did) 394 + const set = new Set(dids) 395 + return set.size === 1 396 + } 397 + 398 + function isFollowingAll( 399 + authors: AppBskyActorDefs.ProfileViewBasic[], 400 + userDid: string, 401 + ): boolean { 402 + return authors.every(a => a.did === userDid || a.viewer?.following) 373 403 }
-12
src/lib/api/feed/merge.ts
··· 193 193 return this.hasMore && this.queue.length === 0 194 194 } 195 195 196 - reset() { 197 - this.cursor = undefined 198 - this.queue = [] 199 - this.hasMore = true 200 - } 201 - 202 196 take(n: number): AppBskyFeedDefs.FeedViewPost[] { 203 197 return this.queue.splice(0, n) 204 198 } ··· 232 226 class MergeFeedSource_Following extends MergeFeedSource { 233 227 tuner = new FeedTuner(this.feedTuners) 234 228 235 - reset() { 236 - super.reset() 237 - this.tuner.reset() 238 - } 239 - 240 229 async fetchNext(n: number) { 241 230 return this._fetchNextInner(n) 242 231 } ··· 249 238 // run the tuner pre-emptively to ensure better mixing 250 239 const slices = this.tuner.tune(res.data.feed, { 251 240 dryRun: false, 252 - maintainOrder: true, 253 241 }) 254 242 res.data.feed = slices.map(slice => slice._feedPost) 255 243 return res
+1 -1
src/state/feed-feedback.tsx
··· 123 123 toString({ 124 124 item: postItem.uri, 125 125 event: 'app.bsky.feed.defs#interactionSeen', 126 - feedContext: postItem.feedContext, 126 + feedContext: slice.feedContext, 127 127 }), 128 128 ) 129 129 sendToFeed()
+6 -13
src/state/preferences/feed-tuners.tsx
··· 19 19 } 20 20 } 21 21 if (feedDesc.startsWith('feedgen')) { 22 - return [ 23 - FeedTuner.dedupReposts, 24 - FeedTuner.preferredLangOnly(langPrefs.contentLanguages), 25 - ] 22 + return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)] 26 23 } 27 24 if (feedDesc.startsWith('list')) { 28 - const feedTuners = [] 29 - 25 + let feedTuners = [] 30 26 if (feedDesc.endsWith('|as_following')) { 31 27 // Same as Following tuners below, copypaste for now. 28 + feedTuners.push(FeedTuner.removeOrphans) 32 29 if (preferences?.feedViewPrefs.hideReposts) { 33 30 feedTuners.push(FeedTuner.removeReposts) 34 - } else { 35 - feedTuners.push(FeedTuner.dedupReposts) 36 31 } 37 32 if (preferences?.feedViewPrefs.hideReplies) { 38 33 feedTuners.push(FeedTuner.removeReplies) ··· 46 41 if (preferences?.feedViewPrefs.hideQuotePosts) { 47 42 feedTuners.push(FeedTuner.removeQuotePosts) 48 43 } 49 - } else { 50 - feedTuners.push(FeedTuner.dedupReposts) 44 + feedTuners.push(FeedTuner.dedupThreads) 51 45 } 52 46 return feedTuners 53 47 } 54 48 if (feedDesc === 'following') { 55 - const feedTuners = [] 49 + const feedTuners = [FeedTuner.removeOrphans] 56 50 57 51 if (preferences?.feedViewPrefs.hideReposts) { 58 52 feedTuners.push(FeedTuner.removeReposts) 59 - } else { 60 - feedTuners.push(FeedTuner.dedupReposts) 61 53 } 62 54 if (preferences?.feedViewPrefs.hideReplies) { 63 55 feedTuners.push(FeedTuner.removeReplies) ··· 71 63 if (preferences?.feedViewPrefs.hideQuotePosts) { 72 64 feedTuners.push(FeedTuner.removeQuotePosts) 73 65 } 66 + feedTuners.push(FeedTuner.dedupThreads) 74 67 75 68 return feedTuners 76 69 }
+23 -55
src/state/queries/post-feed.ts
··· 77 77 uri: string 78 78 post: AppBskyFeedDefs.PostView 79 79 record: AppBskyFeedPost.Record 80 - reason?: 81 - | AppBskyFeedDefs.ReasonRepost 82 - | ReasonFeedSource 83 - | {[k: string]: unknown; $type: string} 84 - feedContext: string | undefined 85 80 moderation: ModerationDecision 86 81 parentAuthor?: AppBskyActorDefs.ProfileViewBasic 87 82 isParentBlocked?: boolean ··· 90 85 export interface FeedPostSlice { 91 86 _isFeedPostSlice: boolean 92 87 _reactKey: string 93 - rootUri: string 94 - isThread: boolean 95 88 items: FeedPostSliceItem[] 89 + isIncompleteThread: boolean 90 + isFallbackMarker: boolean 91 + feedContext: string | undefined 92 + reason?: 93 + | AppBskyFeedDefs.ReasonRepost 94 + | ReasonFeedSource 95 + | {[k: string]: unknown; $type: string} 96 96 } 97 97 98 98 export interface FeedPageUnselected { ··· 313 313 const feedPostSlice: FeedPostSlice = { 314 314 _reactKey: slice._reactKey, 315 315 _isFeedPostSlice: true, 316 - rootUri: slice.uri, 317 - isThread: 318 - slice.items.length > 1 && 319 - slice.items.every( 320 - item => 321 - item.post.author.did === 322 - slice.items[0].post.author.did, 323 - ), 324 - items: slice.items 325 - .map((item, i) => { 326 - if ( 327 - AppBskyFeedPost.isRecord(item.post.record) && 328 - AppBskyFeedPost.validateRecord(item.post.record) 329 - .success 330 - ) { 331 - const parent = item.reply?.parent 332 - let parentAuthor: 333 - | AppBskyActorDefs.ProfileViewBasic 334 - | undefined 335 - if (AppBskyFeedDefs.isPostView(parent)) { 336 - parentAuthor = parent.author 337 - } 338 - if (!parentAuthor) { 339 - parentAuthor = 340 - slice.items[i + 1]?.reply?.grandparentAuthor 341 - } 342 - const replyRef = item.reply 343 - const isParentBlocked = AppBskyFeedDefs.isBlockedPost( 344 - replyRef?.parent, 345 - ) 346 - 347 - const feedPostSliceItem: FeedPostSliceItem = { 348 - _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`, 349 - uri: item.post.uri, 350 - post: item.post, 351 - record: item.post.record, 352 - reason: slice.reason, 353 - feedContext: slice.feedContext, 354 - moderation: moderations[i], 355 - parentAuthor, 356 - isParentBlocked, 357 - } 358 - return feedPostSliceItem 359 - } 360 - return undefined 361 - }) 362 - .filter(n => !!n), 316 + isIncompleteThread: slice.isIncompleteThread, 317 + isFallbackMarker: slice.isFallbackMarker, 318 + feedContext: slice.feedContext, 319 + reason: slice.reason, 320 + items: slice.items.map((item, i) => { 321 + const feedPostSliceItem: FeedPostSliceItem = { 322 + _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`, 323 + uri: item.post.uri, 324 + post: item.post, 325 + record: item.record, 326 + moderation: moderations[i], 327 + parentAuthor: item.parentAuthor, 328 + isParentBlocked: item.isParentBlocked, 329 + } 330 + return feedPostSliceItem 331 + }), 363 332 } 364 333 return feedPostSlice 365 334 }) ··· 442 411 if (post) { 443 412 const slices = page.tuner.tune([post], { 444 413 dryRun: true, 445 - maintainOrder: true, 446 414 }) 447 415 if (slices[0]) { 448 416 return true
+1 -2
src/view/com/posts/Feed.tsx
··· 14 14 import {useLingui} from '@lingui/react' 15 15 import {useQueryClient} from '@tanstack/react-query' 16 16 17 - import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home' 18 17 import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 19 18 import {logEvent, useGate} from '#/lib/statsig/statsig' 20 19 import {logger} from '#/logger' ··· 472 471 } else if (item.type === progressGuideInterstitialType) { 473 472 return <ProgressGuide /> 474 473 } else if (item.type === 'slice') { 475 - if (item.slice.rootUri === FALLBACK_MARKER_POST.post.uri) { 474 + if (item.slice.isFallbackMarker) { 476 475 // HACK 477 476 // tell the user we fell back to discover 478 477 // see home.ts (feed api) for more info
+3 -5
src/view/com/posts/FeedItem.tsx
··· 345 345 postHref={href} 346 346 onOpenAuthor={onOpenAuthor} 347 347 /> 348 - {!isThreadChild && 349 - showReplyTo && 350 - (parentAuthor || isParentBlocked) && ( 351 - <ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} /> 352 - )} 348 + {showReplyTo && (parentAuthor || isParentBlocked) && ( 349 + <ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} /> 350 + )} 353 351 <LabelsOnMyPost post={post} /> 354 352 <PostContent 355 353 moderation={moderation}
+18 -15
src/view/com/posts/FeedSlice.tsx
··· 18 18 slice: FeedPostSlice 19 19 hideTopBorder?: boolean 20 20 }): React.ReactNode => { 21 - if (slice.isThread && slice.items.length > 3) { 21 + if (slice.isIncompleteThread && slice.items.length >= 3) { 22 22 const beforeLast = slice.items.length - 2 23 23 const last = slice.items.length - 1 24 24 return ( ··· 27 27 key={slice.items[0]._reactKey} 28 28 post={slice.items[0].post} 29 29 record={slice.items[0].record} 30 - reason={slice.items[0].reason} 31 - feedContext={slice.items[0].feedContext} 30 + reason={slice.reason} 31 + feedContext={slice.feedContext} 32 32 parentAuthor={slice.items[0].parentAuthor} 33 - showReplyTo={true} 33 + showReplyTo={false} 34 34 moderation={slice.items[0].moderation} 35 35 isThreadParent={isThreadParentAt(slice.items, 0)} 36 36 isThreadChild={isThreadChildAt(slice.items, 0)} 37 37 hideTopBorder={hideTopBorder} 38 38 isParentBlocked={slice.items[0].isParentBlocked} 39 39 /> 40 - <ViewFullThread slice={slice} /> 40 + <ViewFullThread uri={slice.items[0].uri} /> 41 41 <FeedItem 42 42 key={slice.items[beforeLast]._reactKey} 43 43 post={slice.items[beforeLast].post} 44 44 record={slice.items[beforeLast].record} 45 - reason={slice.items[beforeLast].reason} 46 - feedContext={slice.items[beforeLast].feedContext} 45 + reason={undefined} 46 + feedContext={slice.feedContext} 47 47 parentAuthor={slice.items[beforeLast].parentAuthor} 48 - showReplyTo={false} 48 + showReplyTo={ 49 + slice.items[beforeLast].parentAuthor?.did !== 50 + slice.items[beforeLast].post.author.did 51 + } 49 52 moderation={slice.items[beforeLast].moderation} 50 53 isThreadParent={isThreadParentAt(slice.items, beforeLast)} 51 54 isThreadChild={isThreadChildAt(slice.items, beforeLast)} ··· 55 58 key={slice.items[last]._reactKey} 56 59 post={slice.items[last].post} 57 60 record={slice.items[last].record} 58 - reason={slice.items[last].reason} 59 - feedContext={slice.items[last].feedContext} 61 + reason={undefined} 62 + feedContext={slice.feedContext} 60 63 parentAuthor={slice.items[last].parentAuthor} 61 64 showReplyTo={false} 62 65 moderation={slice.items[last].moderation} ··· 76 79 key={item._reactKey} 77 80 post={slice.items[i].post} 78 81 record={slice.items[i].record} 79 - reason={slice.items[i].reason} 80 - feedContext={slice.items[i].feedContext} 82 + reason={i === 0 ? slice.reason : undefined} 83 + feedContext={slice.feedContext} 81 84 moderation={slice.items[i].moderation} 82 85 parentAuthor={slice.items[i].parentAuthor} 83 86 showReplyTo={i === 0} ··· 96 99 FeedSlice = memo(FeedSlice) 97 100 export {FeedSlice} 98 101 99 - function ViewFullThread({slice}: {slice: FeedPostSlice}) { 102 + function ViewFullThread({uri}: {uri: string}) { 100 103 const pal = usePalette('default') 101 104 const itemHref = React.useMemo(() => { 102 - const urip = new AtUri(slice.rootUri) 105 + const urip = new AtUri(uri) 103 106 return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) 104 - }, [slice.rootUri]) 107 + }, [uri]) 105 108 106 109 return ( 107 110 <Link style={[styles.viewFullThread]} href={itemHref} asAnchor noFeedback>