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