Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
120
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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}