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