mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

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

at remove-preload 390 lines 10 kB view raw
1import { 2 AppBskyEmbedRecord, 3 AppBskyEmbedRecordWithMedia, 4 AppBskyFeedDefs, 5 AppBskyFeedPost, 6} from '@atproto/api' 7 8import {isPostInLanguage} from '../../locale/helpers' 9import {ReasonFeedSource} from './feed/types' 10type FeedViewPost = AppBskyFeedDefs.FeedViewPost 11 12export type FeedTunerFn = ( 13 tuner: FeedTuner, 14 slices: FeedViewPostsSlice[], 15) => FeedViewPostsSlice[] 16 17export class FeedViewPostsSlice { 18 _reactKey: string 19 isFlattenedReply = false 20 21 constructor(public items: FeedViewPost[]) { 22 const item = items[0] 23 this._reactKey = `slice-${item.post.uri}-${ 24 item.reason?.indexedAt || item.post.indexedAt 25 }` 26 } 27 28 get uri() { 29 if (this.isFlattenedReply) { 30 return this.items[1].post.uri 31 } 32 return this.items[0].post.uri 33 } 34 35 get ts() { 36 if (this.items[0].reason?.indexedAt) { 37 return this.items[0].reason.indexedAt as string 38 } 39 return this.items[0].post.indexedAt 40 } 41 42 get isThread() { 43 return ( 44 this.items.length > 1 && 45 this.items.every( 46 item => item.post.author.did === this.items[0].post.author.did, 47 ) 48 ) 49 } 50 51 get isFullThread() { 52 return this.isThread && !this.items[0].reply 53 } 54 55 get rootItem() { 56 if (this.isFlattenedReply) { 57 return this.items[1] 58 } 59 return this.items[0] 60 } 61 62 get isReply() { 63 return ( 64 AppBskyFeedPost.isRecord(this.rootItem.post.record) && 65 !!this.rootItem.post.record.reply 66 ) 67 } 68 69 get source(): ReasonFeedSource | undefined { 70 return this.items.find(item => '__source' in item && !!item.__source) 71 ?.__source as ReasonFeedSource 72 } 73 74 containsUri(uri: string) { 75 return !!this.items.find(item => item.post.uri === uri) 76 } 77 78 isNextInThread(uri: string) { 79 return this.items[this.items.length - 1].post.uri === uri 80 } 81 82 insert(item: FeedViewPost) { 83 const selfReplyUri = getSelfReplyUri(item) 84 const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri) 85 if (i !== -1) { 86 this.items.splice(i + 1, 0, item) 87 } else { 88 this.items.push(item) 89 } 90 } 91 92 flattenReplyParent() { 93 if (this.items[0].reply) { 94 const reply = this.items[0].reply 95 if (AppBskyFeedDefs.isPostView(reply.parent)) { 96 this.isFlattenedReply = true 97 this.items.splice(0, 0, {post: reply.parent}) 98 } 99 } 100 } 101 102 isFollowingAllAuthors(userDid: string) { 103 const item = this.rootItem 104 if (item.post.author.did === userDid) { 105 return true 106 } 107 if (AppBskyFeedDefs.isPostView(item.reply?.parent)) { 108 const parent = item.reply?.parent 109 if (parent?.author.did === userDid) { 110 return true 111 } 112 return ( 113 parent?.author.viewer?.following && item.post.author.viewer?.following 114 ) 115 } 116 return false 117 } 118} 119 120export class NoopFeedTuner { 121 reset() {} 122 tune( 123 feed: FeedViewPost[], 124 _opts?: {dryRun: boolean; maintainOrder: boolean}, 125 ): FeedViewPostsSlice[] { 126 return feed.map(item => new FeedViewPostsSlice([item])) 127 } 128} 129 130export class FeedTuner { 131 seenKeys: Set<string> = new Set() 132 seenUris: Set<string> = new Set() 133 134 constructor(public tunerFns: FeedTunerFn[]) {} 135 136 reset() { 137 this.seenKeys.clear() 138 this.seenUris.clear() 139 } 140 141 tune( 142 feed: FeedViewPost[], 143 {dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = { 144 dryRun: false, 145 maintainOrder: false, 146 }, 147 ): FeedViewPostsSlice[] { 148 let slices: FeedViewPostsSlice[] = [] 149 150 // remove posts that are replies, but which don't have the parent 151 // hydrated. this means the parent was either deleted or blocked 152 feed = feed.filter(item => { 153 if ( 154 AppBskyFeedPost.isRecord(item.post.record) && 155 item.post.record.reply && 156 !item.reply 157 ) { 158 return false 159 } 160 return true 161 }) 162 163 if (maintainOrder) { 164 slices = feed.map(item => new FeedViewPostsSlice([item])) 165 } else { 166 // arrange the posts into thread slices 167 for (let i = feed.length - 1; i >= 0; i--) { 168 const item = feed[i] 169 170 const selfReplyUri = getSelfReplyUri(item) 171 if (selfReplyUri) { 172 const index = slices.findIndex(slice => 173 slice.isNextInThread(selfReplyUri), 174 ) 175 176 if (index !== -1) { 177 const parent = slices[index] 178 179 parent.insert(item) 180 181 // If our slice isn't currently on the top, reinsert it to the top. 182 if (index !== 0) { 183 slices.splice(index, 1) 184 slices.unshift(parent) 185 } 186 187 continue 188 } 189 } 190 191 slices.unshift(new FeedViewPostsSlice([item])) 192 } 193 } 194 195 // run the custom tuners 196 for (const tunerFn of this.tunerFns) { 197 slices = tunerFn(this, slices.slice()) 198 } 199 200 // remove any items already "seen" 201 const soonToBeSeenUris: Set<string> = new Set() 202 for (let i = slices.length - 1; i >= 0; i--) { 203 if (!slices[i].isThread && this.seenUris.has(slices[i].uri)) { 204 slices.splice(i, 1) 205 } else { 206 for (const item of slices[i].items) { 207 soonToBeSeenUris.add(item.post.uri) 208 } 209 } 210 } 211 212 // turn non-threads with reply parents into threads 213 for (const slice of slices) { 214 if (!slice.isThread && !slice.items[0].reason && slice.items[0].reply) { 215 const reply = slice.items[0].reply 216 if ( 217 AppBskyFeedDefs.isPostView(reply.parent) && 218 !this.seenUris.has(reply.parent.uri) && 219 !soonToBeSeenUris.has(reply.parent.uri) 220 ) { 221 const uri = reply.parent.uri 222 slice.flattenReplyParent() 223 soonToBeSeenUris.add(uri) 224 } 225 } 226 } 227 228 if (!dryRun) { 229 slices = slices.filter(slice => { 230 if (this.seenKeys.has(slice._reactKey)) { 231 return false 232 } 233 for (const item of slice.items) { 234 this.seenUris.add(item.post.uri) 235 } 236 this.seenKeys.add(slice._reactKey) 237 return true 238 }) 239 } 240 241 return slices 242 } 243 244 static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { 245 for (let i = slices.length - 1; i >= 0; i--) { 246 if (slices[i].isReply) { 247 slices.splice(i, 1) 248 } 249 } 250 return slices 251 } 252 253 static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { 254 for (let i = slices.length - 1; i >= 0; i--) { 255 const reason = slices[i].rootItem.reason 256 if (AppBskyFeedDefs.isReasonRepost(reason)) { 257 slices.splice(i, 1) 258 } 259 } 260 return slices 261 } 262 263 static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { 264 for (let i = slices.length - 1; i >= 0; i--) { 265 const embed = slices[i].rootItem.post.embed 266 if ( 267 AppBskyEmbedRecord.isView(embed) || 268 AppBskyEmbedRecordWithMedia.isView(embed) 269 ) { 270 slices.splice(i, 1) 271 } 272 } 273 return slices 274 } 275 276 static dedupReposts( 277 tuner: FeedTuner, 278 slices: FeedViewPostsSlice[], 279 ): FeedViewPostsSlice[] { 280 // remove duplicates caused by reposts 281 for (let i = 0; i < slices.length; i++) { 282 const item1 = slices[i] 283 for (let j = i + 1; j < slices.length; j++) { 284 const item2 = slices[j] 285 if (item2.isThread) { 286 // dont dedup items that are rendering in a thread as this can cause rendering errors 287 continue 288 } 289 if (item1.containsUri(item2.items[0].post.uri)) { 290 slices.splice(j, 1) 291 j-- 292 } 293 } 294 } 295 return slices 296 } 297 298 static thresholdRepliesOnly({ 299 userDid, 300 minLikes, 301 followedOnly, 302 }: { 303 userDid: string 304 minLikes: number 305 followedOnly: boolean 306 }) { 307 return ( 308 tuner: FeedTuner, 309 slices: FeedViewPostsSlice[], 310 ): FeedViewPostsSlice[] => { 311 // remove any replies without at least minLikes likes 312 for (let i = slices.length - 1; i >= 0; i--) { 313 const slice = slices[i] 314 if (slice.isFullThread || !slice.isReply) { 315 continue 316 } 317 318 const item = slice.rootItem 319 const isRepost = Boolean(item.reason) 320 if (isRepost) { 321 continue 322 } 323 if ((item.post.likeCount || 0) < minLikes) { 324 slices.splice(i, 1) 325 } else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) { 326 slices.splice(i, 1) 327 } 328 } 329 return slices 330 } 331 } 332 333 /** 334 * This function filters a list of FeedViewPostsSlice items based on whether they contain text in a 335 * preferred language. 336 * @param {string[]} preferredLangsCode2 - An array of preferred language codes in ISO 639-1 or ISO 639-2 format. 337 * @returns A function that takes in a `FeedTuner` and an array of `FeedViewPostsSlice` objects and 338 * returns an array of `FeedViewPostsSlice` objects. 339 */ 340 static preferredLangOnly(preferredLangsCode2: string[]) { 341 return ( 342 tuner: FeedTuner, 343 slices: FeedViewPostsSlice[], 344 ): FeedViewPostsSlice[] => { 345 const candidateSlices = slices.slice() 346 347 // early return if no languages have been specified 348 if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) { 349 return slices 350 } 351 352 for (let i = slices.length - 1; i >= 0; i--) { 353 let hasPreferredLang = false 354 for (const item of slices[i].items) { 355 if (isPostInLanguage(item.post, preferredLangsCode2)) { 356 hasPreferredLang = true 357 break 358 } 359 } 360 361 // if item does not fit preferred language, remove it 362 if (!hasPreferredLang) { 363 candidateSlices.splice(i, 1) 364 } 365 } 366 367 // if the language filter cleared out the entire page, return the original set 368 // so that something always shows 369 if (candidateSlices.length === 0) { 370 return slices 371 } 372 373 return candidateSlices 374 } 375 } 376} 377 378function getSelfReplyUri(item: FeedViewPost): string | undefined { 379 if (item.reply) { 380 if ( 381 AppBskyFeedDefs.isPostView(item.reply.parent) && 382 !AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies 383 ) { 384 return item.reply.parent.author.did === item.post.author.did 385 ? item.reply.parent.uri 386 : undefined 387 } 388 } 389 return undefined 390}