unoffical wafrn mirror
wafrn.net
atproto
social-network
activitypub
1import { Expo, type ExpoPushErrorTicket } from 'expo-server-sdk'
2import { logger } from './logger.js'
3import { Notification, PushNotificationToken } from '../models/index.js'
4import { Queue } from 'bullmq'
5import { getAllLocalUserIds } from './cacheGetters/getAllLocalUserIds.js'
6import dompurify from 'isomorphic-dompurify'
7import { completeEnvironment } from './backendOptions.js'
8
9type PushNotificationPayload = {
10 notifications: NotificationBody[]
11 context?: NotificationContext
12}
13
14const sendPushNotificationQueue = new Queue<PushNotificationPayload>('sendPushNotification', {
15 connection: completeEnvironment.bullmqConnection,
16 defaultJobOptions: {
17 removeOnComplete: true,
18 attempts: 3,
19 backoff: {
20 type: 'exponential',
21 delay: 1000
22 }
23 }
24})
25
26export type NotificationBody = {
27 notifiedUserId: string
28 userId: string
29 notificationType: 'FOLLOW' | 'LIKE' | 'REWOOT' | 'MENTION' | 'QUOTE' | 'EMOJIREACT' | 'USERBITE' | 'POSTBITE'
30 postId?: string
31 emojiReactionId?: string
32 createdAt?: Date
33 updatedAt?: Date
34}
35
36export type NotificationContext = {
37 postContent?: string
38 userUrl?: string
39 emoji?: string
40 ignoreDuplicates?: boolean
41}
42
43export async function deleteToken(token: string) {
44 return PushNotificationToken.destroy({
45 where: {
46 token
47 }
48 })
49}
50
51const expoClient = new Expo()
52
53export async function bulkCreateNotifications(notifications: NotificationBody[], context?: NotificationContext) {
54 const localUserIds = await getAllLocalUserIds()
55 const localUserNotifications = notifications.filter((elem) => localUserIds.includes(elem.notifiedUserId))
56 if (localUserNotifications.length > 0) {
57 if (context && context.postContent) {
58 context.postContent = dompurify.sanitize(context.postContent, { ALLOWED_TAGS: [] })
59 }
60 const notificationDate = notifications[0].createdAt ? notifications[0].createdAt : new Date()
61 const timeDiff = Math.abs(new Date().getTime() - notificationDate.getTime())
62 const sendNotifications =
63 timeDiff < 3600 * 1000 ? sendPushNotificationQueue.add('sendPushNotification', { notifications, context }) : null
64 await Promise.all([
65 Notification.bulkCreate(localUserNotifications, { ignoreDuplicates: context?.ignoreDuplicates }),
66 sendNotifications
67 ])
68 }
69}
70
71export async function createNotification(notification: NotificationBody, context?: NotificationContext) {
72 const localUserIds = await getAllLocalUserIds()
73 if (localUserIds.includes(notification.notifiedUserId)) {
74 if (notification.postId && notification.notificationType != 'EMOJIREACT'
75 && notification.notificationType != 'USERBITE' && notification.notificationType != 'POSTBITE') {
76 // lets avoid double existing notifications. Ok may break things with emojireacts and bites
77 const existingNotifications = await Notification.count({
78 where: {
79 userId: notification.userId,
80 notifiedUserId: notification.notifiedUserId,
81 postId: notification.postId,
82 notificationType: notification.notificationType
83 }
84 })
85 if (existingNotifications) {
86 return
87 }
88 }
89 if (context && context.postContent) {
90 context.postContent = dompurify.sanitize(context.postContent, { ALLOWED_TAGS: [] })
91 }
92 const notificationDate = notification.createdAt ? notification.createdAt : new Date()
93 const timeDiff = Math.abs(new Date().getTime() - notificationDate.getTime())
94 const sendNotification =
95 timeDiff < 3600 * 1000
96 ? sendPushNotificationQueue.add('sendPushNotification', {
97 notifications: [notification],
98 context
99 })
100 : null
101 await Promise.all([Notification.create(notification), sendNotification])
102 }
103}
104
105// Error codes reference: https://docs.expo.io/push-notifications/sending-notifications/#individual-errors
106export async function handleDeliveryError(response: ExpoPushErrorTicket) {
107 logger.error(response)
108 const error = response.details?.error
109
110 // do not send notifications again to this token until it is registered again
111 if (error === 'DeviceNotRegistered') {
112 const token = response.details?.expoPushToken
113 if (token) {
114 await deleteToken(token)
115 }
116 }
117}
118
119const verbMap = {
120 LIKE: 'liked',
121 REWOOT: 'rewooted',
122 MENTION: 'replied to',
123 QUOTE: 'quoted',
124 EMOJIREACT: 'reacted to',
125 USERBITE: 'bit',
126 POSTBITE: 'bit'
127}
128
129export function getNotificationTitle(notification: NotificationBody, context?: NotificationContext) {
130 if (notification.notificationType === 'FOLLOW') {
131 return 'New user followed you'
132 }
133
134 if (notification.notificationType === 'USERBITE') {
135 return `${context?.userUrl || 'someone'} bit you`
136 }
137
138 if (notification.notificationType === 'EMOJIREACT' && context?.emoji) {
139 return `${context?.userUrl || 'someone'} reacted with ${context.emoji} to your post`
140 }
141
142 return `${context?.userUrl || 'someone'} ${verbMap[notification.notificationType]} your post`
143}
144
145export function getNotificationBody(notification: NotificationBody, context?: NotificationContext) {
146 if (notification.notificationType === 'FOLLOW' || notification.notificationType === 'USERBITE') {
147 return context?.userUrl ? `@${context?.userUrl.replace(/^@/, '')}` : ''
148 }
149
150 return `${context?.postContent}`
151}