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