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