Bluesky app fork with some witchin' additions 馃挮
at main 521 lines 15 kB view raw
1import { 2 type AppBskyActorDefs, 3 AppBskyEmbedRecord, 4 AppBskyEmbedRecordWithMedia, 5 AppBskyFeedDefs, 6 AppBskyFeedPost, 7} from '@atproto/api' 8 9import * as bsky from '#/types/bsky' 10import {isPostInLanguage} from '../../locale/helpers' 11import {FALLBACK_MARKER_POST} from './feed/home' 12import {type ReasonFeedSource} from './feed/types' 13 14type FeedViewPost = AppBskyFeedDefs.FeedViewPost 15 16export type FeedTunerFn = ( 17 tuner: FeedTuner, 18 slices: FeedViewPostsSlice[], 19 dryRun: boolean, 20) => FeedViewPostsSlice[] 21 22type FeedSliceItem = { 23 post: AppBskyFeedDefs.PostView 24 record: AppBskyFeedPost.Record 25 parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 26 isParentBlocked: boolean 27 isParentNotFound: boolean 28} 29 30type AuthorContext = { 31 author: AppBskyActorDefs.ProfileViewBasic 32 parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 33 grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 34 rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 35} 36 37export class FeedViewPostsSlice { 38 _reactKey: string 39 _feedPost: FeedViewPost 40 items: FeedSliceItem[] 41 isIncompleteThread: boolean 42 isFallbackMarker: boolean 43 isOrphan: boolean 44 isThreadMuted: boolean 45 rootUri: string 46 feedPostUri: string 47 48 constructor(feedPost: FeedViewPost) { 49 const {post, reply, reason} = feedPost 50 this.items = [] 51 this.isIncompleteThread = false 52 this.isFallbackMarker = false 53 this.isOrphan = false 54 this.isThreadMuted = post.viewer?.threadMuted ?? false 55 this.feedPostUri = post.uri 56 if (AppBskyFeedDefs.isPostView(reply?.root)) { 57 this.rootUri = reply.root.uri 58 } else { 59 this.rootUri = post.uri 60 } 61 this._feedPost = feedPost 62 this._reactKey = `slice-${post.uri}-${ 63 feedPost.reason && 'indexedAt' in feedPost.reason 64 ? feedPost.reason.indexedAt 65 : post.indexedAt 66 }` 67 if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) { 68 this.isFallbackMarker = true 69 return 70 } 71 if ( 72 !AppBskyFeedPost.isRecord(post.record) || 73 !bsky.validate(post.record, AppBskyFeedPost.validateRecord) 74 ) { 75 return 76 } 77 const parent = reply?.parent 78 const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent) 79 const isParentNotFound = AppBskyFeedDefs.isNotFoundPost(parent) 80 let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 81 if (AppBskyFeedDefs.isPostView(parent)) { 82 parentAuthor = parent.author 83 } 84 this.items.push({ 85 post, 86 record: post.record, 87 parentAuthor, 88 isParentBlocked, 89 isParentNotFound, 90 }) 91 if (!reply) { 92 if (post.record.reply) { 93 // This reply wasn't properly hydrated by the AppView. 94 this.isOrphan = true 95 this.items[0].isParentNotFound = true 96 } 97 return 98 } 99 if (reason) { 100 return 101 } 102 if ( 103 !AppBskyFeedDefs.isPostView(parent) || 104 !AppBskyFeedPost.isRecord(parent.record) || 105 !bsky.validate(parent.record, AppBskyFeedPost.validateRecord) 106 ) { 107 this.isOrphan = true 108 return 109 } 110 const root = reply.root 111 const rootIsView = 112 AppBskyFeedDefs.isPostView(root) || 113 AppBskyFeedDefs.isBlockedPost(root) || 114 AppBskyFeedDefs.isNotFoundPost(root) 115 /* 116 * If the parent is also the root, we just so happen to have the data we 117 * need to compute if the parent's parent (grandparent) is blocked. This 118 * doesn't always happen, of course, but we can take advantage of it when 119 * it does. 120 */ 121 const grandparent = 122 rootIsView && parent.record.reply?.parent.uri === root.uri 123 ? root 124 : undefined 125 const grandparentAuthor = reply.grandparentAuthor 126 const isGrandparentBlocked = Boolean( 127 grandparent && AppBskyFeedDefs.isBlockedPost(grandparent), 128 ) 129 const isGrandparentNotFound = Boolean( 130 grandparent && AppBskyFeedDefs.isNotFoundPost(grandparent), 131 ) 132 this.items.unshift({ 133 post: parent, 134 record: parent.record, 135 parentAuthor: grandparentAuthor, 136 isParentBlocked: isGrandparentBlocked, 137 isParentNotFound: isGrandparentNotFound, 138 }) 139 if (isGrandparentBlocked) { 140 this.isOrphan = true 141 // Keep going, it might still have a root, and we need this for thread 142 // de-deduping 143 } 144 if ( 145 !AppBskyFeedDefs.isPostView(root) || 146 !AppBskyFeedPost.isRecord(root.record) || 147 !bsky.validate(root.record, AppBskyFeedPost.validateRecord) 148 ) { 149 this.isOrphan = true 150 return 151 } 152 if (root.uri === parent.uri) { 153 return 154 } 155 this.items.unshift({ 156 post: root, 157 record: root.record, 158 isParentBlocked: false, 159 isParentNotFound: false, 160 parentAuthor: undefined, 161 }) 162 if (parent.record.reply?.parent.uri !== root.uri) { 163 this.isIncompleteThread = true 164 } 165 } 166 167 get isQuotePost() { 168 const embed = this._feedPost.post.embed 169 return ( 170 AppBskyEmbedRecord.isView(embed) || 171 AppBskyEmbedRecordWithMedia.isView(embed) 172 ) 173 } 174 175 get isReply() { 176 return ( 177 AppBskyFeedPost.isRecord(this._feedPost.post.record) && 178 !!this._feedPost.post.record.reply 179 ) 180 } 181 182 get reason() { 183 return '__source' in this._feedPost 184 ? (this._feedPost.__source as ReasonFeedSource) 185 : this._feedPost.reason 186 } 187 188 get feedContext() { 189 return this._feedPost.feedContext 190 } 191 192 get reqId() { 193 return this._feedPost.reqId 194 } 195 196 get isRepost() { 197 const reason = this._feedPost.reason 198 return AppBskyFeedDefs.isReasonRepost(reason) 199 } 200 201 get likeCount() { 202 return this._feedPost.post.likeCount ?? 0 203 } 204 205 containsUri(uri: string) { 206 return !!this.items.find(item => item.post.uri === uri) 207 } 208 209 getAuthors(): AuthorContext { 210 const feedPost = this._feedPost 211 let author: AppBskyActorDefs.ProfileViewBasic = feedPost.post.author 212 let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 213 let grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 214 let rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 215 if (feedPost.reply) { 216 if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) { 217 parentAuthor = feedPost.reply.parent.author 218 } 219 if (feedPost.reply.grandparentAuthor) { 220 grandparentAuthor = feedPost.reply.grandparentAuthor 221 } 222 if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) { 223 rootAuthor = feedPost.reply.root.author 224 } 225 } 226 return { 227 author, 228 parentAuthor, 229 grandparentAuthor, 230 rootAuthor, 231 } 232 } 233} 234 235export class FeedTuner { 236 seenKeys: Set<string> = new Set() 237 seenUris: Set<string> = new Set() 238 seenRootUris: Set<string> = new Set() 239 240 constructor(public tunerFns: FeedTunerFn[]) {} 241 242 tune( 243 feed: FeedViewPost[], 244 {dryRun}: {dryRun: boolean} = { 245 dryRun: false, 246 }, 247 ): FeedViewPostsSlice[] { 248 let slices: FeedViewPostsSlice[] = feed 249 .map(item => new FeedViewPostsSlice(item)) 250 .filter(s => s.items.length > 0 || s.isFallbackMarker) 251 252 // run the custom tuners 253 for (const tunerFn of this.tunerFns) { 254 slices = tunerFn(this, slices.slice(), dryRun) 255 } 256 257 slices = slices.filter(slice => { 258 if (this.seenKeys.has(slice._reactKey)) { 259 return false 260 } 261 // Some feeds, like Following, dedupe by thread, so you only see the most recent reply. 262 // However, we don't want per-thread dedupe for author feeds (where we need to show every post) 263 // or for feedgens (where we want to let the feed serve multiple replies if it chooses to). 264 // To avoid showing the same context (root and/or parent) more than once, we do last resort 265 // per-post deduplication. It hides already seen posts as long as this doesn't break the thread. 266 for (let i = 0; i < slice.items.length; i++) { 267 const item = slice.items[i] 268 if (this.seenUris.has(item.post.uri)) { 269 if (i === 0) { 270 // Omit contiguous seen leading items. 271 // For example, [A -> B -> C], [A -> D -> E], [A -> D -> F] 272 // would turn into [A -> B -> C], [D -> E], [F]. 273 slice.items.splice(0, 1) 274 i-- 275 } 276 if (i === slice.items.length - 1) { 277 // If the last item in the slice was already seen, omit the whole slice. 278 // This means we'd miss its parents, but the user can "show more" to see them. 279 // For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C] 280 // would get collapsed into [A ... E -> F], with B/C/D considered seen. 281 return false 282 } 283 } else { 284 if (!dryRun) { 285 // Reposting a reply elevates it to top-level, so its parent/root won't be displayed. 286 // Disable in-thread dedupe for this case since we don't want to miss them later. 287 const disableDedupe = slice.isReply && slice.isRepost 288 if (!disableDedupe) { 289 this.seenUris.add(item.post.uri) 290 } 291 } 292 } 293 } 294 if (!dryRun) { 295 this.seenKeys.add(slice._reactKey) 296 } 297 return true 298 }) 299 300 return slices 301 } 302 303 static removeReplies( 304 tuner: FeedTuner, 305 slices: FeedViewPostsSlice[], 306 _dryRun: boolean, 307 ) { 308 for (let i = 0; i < slices.length; i++) { 309 const slice = slices[i] 310 if ( 311 slice.isReply && 312 !slice.isRepost && 313 // This is not perfect but it's close as we can get to 314 // detecting threads without having to peek ahead. 315 !areSameAuthor(slice.getAuthors()) 316 ) { 317 slices.splice(i, 1) 318 i-- 319 } 320 } 321 return slices 322 } 323 324 static removeReposts( 325 tuner: FeedTuner, 326 slices: FeedViewPostsSlice[], 327 _dryRun: boolean, 328 ) { 329 for (let i = 0; i < slices.length; i++) { 330 if (slices[i].isRepost) { 331 slices.splice(i, 1) 332 i-- 333 } 334 } 335 return slices 336 } 337 338 static removeQuotePosts( 339 tuner: FeedTuner, 340 slices: FeedViewPostsSlice[], 341 _dryRun: boolean, 342 ) { 343 for (let i = 0; i < slices.length; i++) { 344 if (slices[i].isQuotePost) { 345 slices.splice(i, 1) 346 i-- 347 } 348 } 349 return slices 350 } 351 352 static removeOrphans( 353 tuner: FeedTuner, 354 slices: FeedViewPostsSlice[], 355 _dryRun: boolean, 356 ) { 357 for (let i = 0; i < slices.length; i++) { 358 if (slices[i].isOrphan) { 359 slices.splice(i, 1) 360 i-- 361 } 362 } 363 return slices 364 } 365 366 static removeMutedThreads( 367 tuner: FeedTuner, 368 slices: FeedViewPostsSlice[], 369 _dryRun: boolean, 370 ) { 371 for (let i = 0; i < slices.length; i++) { 372 if (slices[i].isThreadMuted) { 373 slices.splice(i, 1) 374 i-- 375 } 376 } 377 return slices 378 } 379 380 static dedupThreads( 381 tuner: FeedTuner, 382 slices: FeedViewPostsSlice[], 383 dryRun: boolean, 384 ): FeedViewPostsSlice[] { 385 for (let i = 0; i < slices.length; i++) { 386 const rootUri = slices[i].rootUri 387 if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) { 388 slices.splice(i, 1) 389 i-- 390 } else { 391 if (!dryRun) { 392 tuner.seenRootUris.add(rootUri) 393 } 394 } 395 } 396 return slices 397 } 398 399 static followedRepliesOnly({userDid}: {userDid: string}) { 400 return ( 401 tuner: FeedTuner, 402 slices: FeedViewPostsSlice[], 403 _dryRun: boolean, 404 ): FeedViewPostsSlice[] => { 405 for (let i = 0; i < slices.length; i++) { 406 const slice = slices[i] 407 if ( 408 slice.isReply && 409 !slice.isRepost && 410 !shouldDisplayReplyInFollowing(slice.getAuthors(), userDid) 411 ) { 412 slices.splice(i, 1) 413 i-- 414 } 415 } 416 return slices 417 } 418 } 419 420 /** 421 * This function filters a list of FeedViewPostsSlice items based on whether they contain text in a 422 * preferred language. 423 * @param {string[]} preferredLangsCode2 - An array of preferred language codes in ISO 639-1 or ISO 639-2 format. 424 * @returns A function that takes in a `FeedTuner` and an array of `FeedViewPostsSlice` objects and 425 * returns an array of `FeedViewPostsSlice` objects. 426 */ 427 static preferredLangOnly(preferredLangsCode2: string[]) { 428 return ( 429 tuner: FeedTuner, 430 slices: FeedViewPostsSlice[], 431 _dryRun: boolean, 432 ): FeedViewPostsSlice[] => { 433 // early return if no languages have been specified 434 if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) { 435 return slices 436 } 437 438 const candidateSlices = slices.filter(slice => { 439 for (const item of slice.items) { 440 if (isPostInLanguage(item.post, preferredLangsCode2)) { 441 return true 442 } 443 } 444 // if item does not fit preferred language, remove it 445 return false 446 }) 447 448 // if the language filter cleared out the entire page, return the original set 449 // so that something always shows 450 if (candidateSlices.length === 0) { 451 return slices 452 } 453 454 return candidateSlices 455 } 456 } 457} 458 459function areSameAuthor(authors: AuthorContext): boolean { 460 const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors 461 const authorDid = author.did 462 if (parentAuthor && parentAuthor.did !== authorDid) { 463 return false 464 } 465 if (grandparentAuthor && grandparentAuthor.did !== authorDid) { 466 return false 467 } 468 if (rootAuthor && rootAuthor.did !== authorDid) { 469 return false 470 } 471 return true 472} 473 474function shouldDisplayReplyInFollowing( 475 authors: AuthorContext, 476 userDid: string, 477): boolean { 478 const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors 479 if (!isSelfOrFollowing(author, userDid)) { 480 // Only show replies from self or people you follow. 481 return false 482 } 483 if ( 484 (!parentAuthor || parentAuthor.did === author.did) && 485 (!rootAuthor || rootAuthor.did === author.did) && 486 (!grandparentAuthor || grandparentAuthor.did === author.did) 487 ) { 488 // Always show self-threads. 489 return true 490 } 491 // From this point on we need at least one more reason to show it. 492 if ( 493 parentAuthor && 494 parentAuthor.did !== author.did && 495 isSelfOrFollowing(parentAuthor, userDid) 496 ) { 497 return true 498 } 499 if ( 500 grandparentAuthor && 501 grandparentAuthor.did !== author.did && 502 isSelfOrFollowing(grandparentAuthor, userDid) 503 ) { 504 return true 505 } 506 if ( 507 rootAuthor && 508 rootAuthor.did !== author.did && 509 isSelfOrFollowing(rootAuthor, userDid) 510 ) { 511 return true 512 } 513 return false 514} 515 516function isSelfOrFollowing( 517 profile: AppBskyActorDefs.ProfileViewBasic, 518 userDid: string, 519) { 520 return Boolean(profile.did === userDid || profile.viewer?.following) 521}