Bluesky app fork with some witchin' additions 馃挮
at readme-update 318 lines 8.7 kB view raw
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}