forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 type AppBskyFeedDefs,
3 AppBskyFeedLike,
4 AppBskyFeedPost,
5 AppBskyFeedRepost,
6 type AppBskyGraphDefs,
7 AppBskyGraphStarterpack,
8 type AppBskyNotificationListNotifications,
9 type BskyAgent,
10 hasMutedWord,
11 moderateNotification,
12 type ModerationOpts,
13} from '@atproto/api'
14import {type QueryClient} from '@tanstack/react-query'
15import chunk from 'lodash.chunk'
16
17import {labelIsHideableOffense} from '#/lib/moderation'
18import * as bsky from '#/types/bsky'
19import {precacheProfile} from '../profile'
20import {
21 type FeedNotification,
22 type FeedPage,
23 type NotificationType,
24} from './types'
25
26const GROUPABLE_REASONS = [
27 'like',
28 'repost',
29 'follow',
30 'like-via-repost',
31 'repost-via-repost',
32 'subscribed-post',
33]
34const MS_1HR = 1e3 * 60 * 60
35const MS_2DAY = MS_1HR * 48
36
37// exported api
38// =
39
40export async function fetchPage({
41 agent,
42 cursor,
43 limit,
44 queryClient,
45 moderationOpts,
46 fetchAdditionalData,
47 reasons,
48}: {
49 agent: BskyAgent
50 cursor: string | undefined
51 limit: number
52 queryClient: QueryClient
53 moderationOpts: ModerationOpts | undefined
54 fetchAdditionalData: boolean
55 reasons: string[]
56}): Promise<{
57 page: FeedPage
58 indexedAt: string | undefined
59}> {
60 const res = await agent.listNotifications({
61 limit,
62 cursor,
63 reasons,
64 })
65
66 const indexedAt = res.data.notifications[0]?.indexedAt
67
68 // filter out notifs by mod rules
69 const notifs = res.data.notifications.filter(
70 notif => !shouldFilterNotif(notif, moderationOpts),
71 )
72
73 // group notifications which are essentially similar (follows, likes on a post)
74 let notifsGrouped = groupNotifications(notifs)
75
76 // we fetch subjects of notifications (usually posts) now instead of lazily
77 // in the UI to avoid relayouts
78 if (fetchAdditionalData) {
79 const subjects = await fetchSubjects(agent, notifsGrouped)
80 for (const notif of notifsGrouped) {
81 if (notif.subjectUri) {
82 if (
83 notif.type === 'starterpack-joined' &&
84 notif.notification.reasonSubject
85 ) {
86 notif.subject = subjects.starterPacks.get(
87 notif.notification.reasonSubject,
88 )
89 } else {
90 notif.subject = subjects.posts.get(notif.subjectUri)
91 if (notif.subject) {
92 precacheProfile(queryClient, notif.subject.author)
93 }
94 }
95 }
96 }
97 }
98
99 let seenAt = res.data.seenAt ? new Date(res.data.seenAt) : new Date()
100 if (Number.isNaN(seenAt.getTime())) {
101 seenAt = new Date()
102 }
103
104 return {
105 page: {
106 cursor: res.data.cursor,
107 seenAt,
108 items: notifsGrouped,
109 priority: res.data.priority ?? false,
110 },
111 indexedAt,
112 }
113}
114
115// internal methods
116// =
117
118export function shouldFilterNotif(
119 notif: AppBskyNotificationListNotifications.Notification,
120 moderationOpts: ModerationOpts | undefined,
121): boolean {
122 const containsImperative = !!notif.author.labels?.some(labelIsHideableOffense)
123 if (containsImperative) {
124 return true
125 }
126 if (!moderationOpts) {
127 return false
128 }
129 if (
130 notif.reason === 'subscribed-post' &&
131 bsky.dangerousIsType<AppBskyFeedPost.Record>(
132 notif.record,
133 AppBskyFeedPost.isRecord,
134 ) &&
135 hasMutedWord({
136 mutedWords: moderationOpts.prefs.mutedWords,
137 text: notif.record.text,
138 facets: notif.record.facets,
139 outlineTags: notif.record.tags,
140 languages: notif.record.langs,
141 actor: notif.author,
142 })
143 ) {
144 return true
145 }
146 if (notif.author.viewer?.following) {
147 return false
148 }
149 return moderateNotification(notif, moderationOpts).ui('contentList').filter
150}
151
152export function groupNotifications(
153 notifs: AppBskyNotificationListNotifications.Notification[],
154): FeedNotification[] {
155 const groupedNotifs: FeedNotification[] = []
156 for (const notif of notifs) {
157 const ts = +new Date(notif.indexedAt)
158 let grouped = false
159 if (GROUPABLE_REASONS.includes(notif.reason)) {
160 for (const groupedNotif of groupedNotifs) {
161 const ts2 = +new Date(groupedNotif.notification.indexedAt)
162 if (
163 Math.abs(ts2 - ts) < MS_2DAY &&
164 notif.reason === groupedNotif.notification.reason &&
165 notif.reasonSubject === groupedNotif.notification.reasonSubject &&
166 (notif.author.did !== groupedNotif.notification.author.did ||
167 notif.reason === 'subscribed-post')
168 ) {
169 const nextIsFollowBack =
170 notif.reason === 'follow' && notif.author.viewer?.following
171 const prevIsFollowBack =
172 groupedNotif.notification.reason === 'follow' &&
173 groupedNotif.notification.author.viewer?.following
174 const shouldUngroup = nextIsFollowBack || prevIsFollowBack
175 if (!shouldUngroup) {
176 groupedNotif.additional = groupedNotif.additional || []
177 groupedNotif.additional.push(notif)
178 grouped = true
179 break
180 }
181 }
182 }
183 }
184 if (!grouped) {
185 const type = toKnownType(notif)
186 if (type !== 'starterpack-joined') {
187 groupedNotifs.push({
188 _reactKey: `notif-${notif.uri}-${notif.reason}`,
189 type,
190 notification: notif,
191 subjectUri: getSubjectUri(type, notif),
192 })
193 } else {
194 groupedNotifs.push({
195 _reactKey: `notif-${notif.uri}-${notif.reason}`,
196 type: 'starterpack-joined',
197 notification: notif,
198 subjectUri: notif.uri,
199 })
200 }
201 }
202 }
203 return groupedNotifs
204}
205
206async function fetchSubjects(
207 agent: BskyAgent,
208 groupedNotifs: FeedNotification[],
209): Promise<{
210 posts: Map<string, AppBskyFeedDefs.PostView>
211 starterPacks: Map<string, AppBskyGraphDefs.StarterPackViewBasic>
212}> {
213 const postUris = new Set<string>()
214 const packUris = new Set<string>()
215 for (const notif of groupedNotifs) {
216 if (notif.subjectUri?.includes('app.bsky.feed.post')) {
217 postUris.add(notif.subjectUri)
218 } else if (
219 notif.notification.reasonSubject?.includes('app.bsky.graph.starterpack')
220 ) {
221 packUris.add(notif.notification.reasonSubject)
222 }
223 }
224 const postUriChunks = chunk(Array.from(postUris), 25)
225 const packUriChunks = chunk(Array.from(packUris), 25)
226 const postsChunks = await Promise.all(
227 postUriChunks.map(uris =>
228 agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts),
229 ),
230 )
231 const packsChunks = await Promise.all(
232 packUriChunks.map(uris =>
233 agent.app.bsky.graph
234 .getStarterPacks({uris})
235 .then(res => res.data.starterPacks),
236 ),
237 )
238 const postsMap = new Map<string, AppBskyFeedDefs.PostView>()
239 const packsMap = new Map<string, AppBskyGraphDefs.StarterPackViewBasic>()
240 for (const post of postsChunks.flat()) {
241 if (AppBskyFeedPost.isRecord(post.record)) {
242 postsMap.set(post.uri, post)
243 }
244 }
245 for (const pack of packsChunks.flat()) {
246 if (AppBskyGraphStarterpack.isRecord(pack.record)) {
247 packsMap.set(pack.uri, pack)
248 }
249 }
250 return {
251 posts: postsMap,
252 starterPacks: packsMap,
253 }
254}
255
256function toKnownType(
257 notif: AppBskyNotificationListNotifications.Notification,
258): NotificationType {
259 if (notif.reason === 'like') {
260 if (notif.reasonSubject?.includes('feed.generator')) {
261 return 'feedgen-like'
262 }
263 return 'post-like'
264 }
265 if (
266 notif.reason === 'repost' ||
267 notif.reason === 'mention' ||
268 notif.reason === 'reply' ||
269 notif.reason === 'quote' ||
270 notif.reason === 'follow' ||
271 notif.reason === 'starterpack-joined' ||
272 notif.reason === 'verified' ||
273 notif.reason === 'unverified' ||
274 notif.reason === 'like-via-repost' ||
275 notif.reason === 'repost-via-repost' ||
276 notif.reason === 'subscribed-post' ||
277 notif.reason === 'contact-match'
278 ) {
279 return notif.reason as NotificationType
280 }
281 return 'unknown'
282}
283
284function getSubjectUri(
285 type: NotificationType,
286 notif: AppBskyNotificationListNotifications.Notification,
287): string | undefined {
288 if (
289 type === 'reply' ||
290 type === 'quote' ||
291 type === 'mention' ||
292 type === 'subscribed-post'
293 ) {
294 return notif.uri
295 } else if (
296 type === 'post-like' ||
297 type === 'repost' ||
298 type === 'like-via-repost' ||
299 type === 'repost-via-repost'
300 ) {
301 if (
302 bsky.dangerousIsType<AppBskyFeedRepost.Record>(
303 notif.record,
304 AppBskyFeedRepost.isRecord,
305 ) ||
306 bsky.dangerousIsType<AppBskyFeedLike.Record>(
307 notif.record,
308 AppBskyFeedLike.isRecord,
309 )
310 ) {
311 return typeof notif.record.subject?.uri === 'string'
312 ? notif.record.subject?.uri
313 : undefined
314 }
315 } else if (type === 'feedgen-like') {
316 return notif.reasonSubject
317 }
318}