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'
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 // lets avoid double existing notifications. Ok may break things with emojireacts
76 const existingNotifications = await Notification.count({
77 where: {
78 userId: notification.userId,
79 notifiedUserId: notification.notifiedUserId,
80 postId: notification.postId,
81 notificationType: notification.notificationType
82 }
83 })
84 if (existingNotifications) {
85 return
86 }
87 }
88 if (context && context.postContent) {
89 context.postContent = dompurify.sanitize(context.postContent, { ALLOWED_TAGS: [] })
90 }
91 const notificationDate = notification.createdAt ? notification.createdAt : new Date()
92 const timeDiff = Math.abs(new Date().getTime() - notificationDate.getTime())
93 const sendNotification =
94 timeDiff < 3600 * 1000
95 ? sendPushNotificationQueue.add('sendPushNotification', {
96 notifications: [notification],
97 context
98 })
99 : null
100 await Promise.all([Notification.create(notification), sendNotification])
101 }
102}
103
104// Error codes reference: https://docs.expo.io/push-notifications/sending-notifications/#individual-errors
105export async function handleDeliveryError(response: ExpoPushErrorTicket) {
106 logger.error(response)
107 const error = response.details?.error
108
109 // do not send notifications again to this token until it is registered again
110 if (error === 'DeviceNotRegistered') {
111 const token = response.details?.expoPushToken
112 if (token) {
113 await deleteToken(token)
114 }
115 }
116}
117
118const verbMap = {
119 LIKE: 'liked',
120 REWOOT: 'rewooted',
121 MENTION: 'replied to',
122 QUOTE: 'quoted',
123 EMOJIREACT: 'reacted to'
124}
125
126export function getNotificationTitle(notification: NotificationBody, context?: NotificationContext) {
127 if (notification.notificationType === 'FOLLOW') {
128 return 'New user followed you'
129 }
130
131 if (notification.notificationType === 'EMOJIREACT' && context?.emoji) {
132 return `${context?.userUrl || 'someone'} reacted with ${context.emoji} to your post`
133 }
134
135 return `${context?.userUrl || 'someone'} ${verbMap[notification.notificationType]} your post`
136}
137
138export function getNotificationBody(notification: NotificationBody, context?: NotificationContext) {
139 if (notification.notificationType === 'FOLLOW') {
140 return context?.userUrl ? `@${context?.userUrl.replace(/^@/, '')}` : ''
141 }
142
143 return `${context?.postContent}`
144}