unoffical wafrn mirror wafrn.net
atproto social-network activitypub
at development 175 lines 5.6 kB view raw
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}