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 notifiedUserUrl?: string,
35 detached: boolean
36}
37
38export type NotificationContext = {
39 postContent?: string
40 userUrl?: string
41 emoji?: string
42 ignoreDuplicates?: boolean
43}
44
45export async function deleteToken(token: string) {
46 return PushNotificationToken.destroy({
47 where: {
48 token
49 }
50 })
51}
52
53const expoClient = new Expo()
54
55export async function bulkCreateNotifications(notifications: NotificationBody[], context?: NotificationContext) {
56 const localUserNotifications = notifications
57 if (localUserNotifications.length > 0) {
58 if (context && context.postContent) {
59 context.postContent = dompurify.sanitize(context.postContent, { ALLOWED_TAGS: [] })
60 }
61 const notificationDate = notifications[0].createdAt ? notifications[0].createdAt : new Date()
62 const timeDiff = Math.abs(new Date().getTime() - notificationDate.getTime())
63 const sendNotifications =
64 timeDiff < 3600 * 1000 ? sendPushNotificationQueue.add('sendPushNotification', { notifications: notifications.filter(elem => !elem.detached), context }) : null
65 await Promise.all([
66 Notification.bulkCreate(localUserNotifications, { ignoreDuplicates: context?.ignoreDuplicates }),
67 sendNotifications
68 ])
69 }
70}
71
72export async function createNotification(notification: NotificationBody, context?: NotificationContext) {
73
74 if (
75 notification.postId &&
76 notification.notificationType != 'EMOJIREACT' &&
77 notification.notificationType != 'USERBITE' &&
78 notification.notificationType != 'POSTBITE'
79 ) {
80 // lets avoid double existing notifications. Ok may break things with emojireacts and bites
81 const existingNotifications = await Notification.count({
82 where: {
83 userId: notification.userId,
84 notifiedUserId: notification.notifiedUserId,
85 postId: notification.postId,
86 notificationType: notification.notificationType
87 }
88 })
89 if (existingNotifications) {
90 return
91 }
92 }
93 if (context && context.postContent) {
94 context.postContent = dompurify.sanitize(context.postContent, { ALLOWED_TAGS: [] })
95 }
96 const notificationDate = notification.createdAt ? notification.createdAt : new Date()
97 const timeDiff = Math.abs(new Date().getTime() - notificationDate.getTime())
98
99 const sendNotification =
100 timeDiff < 3600 * 1000 && !notification.detached
101 ? sendPushNotificationQueue.add('sendPushNotification', {
102 notifications: [notification],
103 context
104 })
105 : null
106 await Promise.all([Notification.create(notification), sendNotification])
107
108}
109
110// Error codes reference: https://docs.expo.io/push-notifications/sending-notifications/#individual-errors
111export async function handleDeliveryError(response: ExpoPushErrorTicket) {
112 logger.error(response)
113 const error = response.details?.error
114
115 // do not send notifications again to this token until it is registered again
116 if (error === 'DeviceNotRegistered') {
117 const token = response.details?.expoPushToken
118 if (token) {
119 await deleteToken(token)
120 }
121 }
122}
123
124const verbMap = {
125 LIKE: 'liked',
126 REWOOT: 'rewooted',
127 MENTION: 'replied to',
128 QUOTE: 'quoted',
129 EMOJIREACT: 'reacted to',
130 USERBITE: 'bit',
131 POSTBITE: 'bit'
132}
133
134function formatYou(url?: string) {
135 if (!url) {
136 return 'you'
137 }
138 if (!url.startsWith('@')) {
139 url = `@${url}`
140 }
141 return url
142}
143function formatYourPost(url?: string) {
144 const fragment = formatYou(url)
145 if (fragment === 'you') {
146 return 'your post'
147 }
148 return `a ${url} post`
149}
150
151export function getNotificationTitle(notification: NotificationBody, context?: NotificationContext) {
152 const you = formatYou(notification.notifiedUserUrl)
153 const yourPost = formatYourPost(notification.notifiedUserUrl)
154 if (notification.notificationType === 'FOLLOW') {
155 return `New user followed ${you}`
156 }
157
158 if (notification.notificationType === 'USERBITE') {
159 return `${context?.userUrl || 'someone'} bit ${you}`
160 }
161
162 if (notification.notificationType === 'EMOJIREACT' && context?.emoji) {
163 return `${context?.userUrl || 'someone'} reacted with ${context.emoji} to ${yourPost}`
164 }
165
166 return `${context?.userUrl || 'someone'} ${verbMap[notification.notificationType]} ${yourPost}`
167}
168
169export function getNotificationBody(notification: NotificationBody, context?: NotificationContext) {
170 if (notification.notificationType === 'FOLLOW' || notification.notificationType === 'USERBITE') {
171 return context?.userUrl ? `@${context?.userUrl.replace(/^@/, '')}` : ''
172 }
173
174 return `${context?.postContent}`
175}