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