mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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}