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