2024-03-19 20:48:13 +00:00
import webPush from 'web-push'
import removeMd from 'remove-markdown'
2024-07-01 20:51:59 +00:00
import { COMMENT _DEPTH _LIMIT , FOUND _BLURBS , LOST _BLURBS } from './constants'
2023-09-26 20:27:55 +00:00
import { msatsToSats , numWithUnits } from './format'
2024-03-20 00:37:31 +00:00
import models from '@/api/models'
2024-05-12 18:55:56 +00:00
import { isMuted } from '@/lib/user'
2024-03-19 20:48:13 +00:00
const webPushEnabled = process . env . NODE _ENV === 'production' ||
( process . env . VAPID _MAILTO && process . env . NEXT _PUBLIC _VAPID _PUBKEY && process . env . VAPID _PRIVKEY )
if ( webPushEnabled ) {
webPush . setVapidDetails (
process . env . VAPID _MAILTO ,
process . env . NEXT _PUBLIC _VAPID _PUBKEY ,
process . env . VAPID _PRIVKEY
)
} else {
console . warn ( 'VAPID_* env vars not set, skipping webPush setup' )
}
const createPayload = ( notification ) => {
// https://web.dev/push-notifications-display-a-notification/#visual-options
let { title , body , ... options } = notification
if ( body ) body = removeMd ( body )
return JSON . stringify ( {
title ,
options : {
body ,
timestamp : Date . now ( ) ,
icon : '/icons/icon_x96.png' ,
... options
}
} )
}
const createUserFilter = ( tag ) => {
// filter users by notification settings
const tagMap = {
REPLY : 'noteAllDescendants' ,
MENTION : 'noteMentions' ,
2024-06-03 17:12:42 +00:00
ITEM _MENTION : 'noteItemMentions' ,
2024-03-19 20:48:13 +00:00
TIP : 'noteItemSats' ,
FORWARDEDTIP : 'noteForwardedSats' ,
REFERRAL : 'noteInvites' ,
INVITE : 'noteInvites' ,
EARN : 'noteEarning' ,
DEPOSIT : 'noteDeposits' ,
2024-03-25 20:20:11 +00:00
WITHDRAWAL : 'noteWithdrawals' ,
2024-03-19 20:48:13 +00:00
STREAK : 'noteCowboyHat'
}
const key = tagMap [ tag . split ( '-' ) [ 0 ] ]
return key ? { user : { [ key ] : true } } : undefined
}
const createItemUrl = async ( { id } ) => {
const [ rootItem ] = await models . $queryRawUnsafe (
'SELECT subpath(path, -LEAST(nlevel(path), $1::INTEGER), 1)::text AS id FROM "Item" WHERE id = $2::INTEGER' ,
COMMENT _DEPTH _LIMIT + 1 , Number ( id )
)
return ` /items/ ${ rootItem . id } ` + ( rootItem . id !== id ? ` ?commentId= ${ id } ` : '' )
}
const sendNotification = ( subscription , payload ) => {
if ( ! webPushEnabled ) {
console . warn ( 'webPush not configured. skipping notification' )
return
}
const { id , endpoint , p256dh , auth } = subscription
return webPush . sendNotification ( { endpoint , keys : { p256dh , auth } } , payload )
. catch ( async ( err ) => {
if ( err . statusCode === 400 ) {
console . log ( '[webPush] invalid request: ' , err )
} else if ( [ 401 , 403 ] . includes ( err . statusCode ) ) {
console . log ( '[webPush] auth error: ' , err )
} else if ( err . statusCode === 404 || err . statusCode === 410 ) {
console . log ( '[webPush] subscription has expired or is no longer valid: ' , err )
const deletedSubscripton = await models . pushSubscription . delete ( { where : { id } } )
console . log ( ` [webPush] deleted subscription ${ id } of user ${ deletedSubscripton . userId } due to push error ` )
} else if ( err . statusCode === 413 ) {
console . log ( '[webPush] payload too large: ' , err )
} else if ( err . statusCode === 429 ) {
console . log ( '[webPush] too many requests: ' , err )
} else {
console . log ( '[webPush] error: ' , err )
}
} )
}
2024-03-19 22:43:04 +00:00
async function sendUserNotification ( userId , notification ) {
2024-03-19 20:48:13 +00:00
try {
2024-03-21 00:59:48 +00:00
if ( ! userId ) {
throw new Error ( 'user id is required' )
}
2024-03-19 20:48:13 +00:00
notification . data ? ? = { }
if ( notification . item ) {
notification . data . url ? ? = await createItemUrl ( notification . item )
notification . data . itemId ? ? = notification . item . id
delete notification . item
}
const userFilter = createUserFilter ( notification . tag )
const payload = createPayload ( notification )
const subscriptions = await models . pushSubscription . findMany ( {
where : { userId , ... userFilter }
} )
await Promise . allSettled (
subscriptions . map ( subscription => sendNotification ( subscription , payload ) )
)
} catch ( err ) {
console . log ( '[webPush] error sending user notification: ' , err )
}
}
export async function replyToSubscription ( subscriptionId , notification ) {
try {
const payload = createPayload ( notification )
const subscription = await models . pushSubscription . findUnique ( { where : { id : subscriptionId } } )
await sendNotification ( subscription , payload )
} catch ( err ) {
console . log ( '[webPush] error sending subscription reply: ' , err )
}
}
2023-09-26 20:27:55 +00:00
export const notifyUserSubscribers = async ( { models , item } ) => {
try {
const isPost = ! ! item . title
2024-05-12 18:55:56 +00:00
const userSubsExcludingMutes = await models . $queryRawUnsafe ( `
SELECT "UserSubscription" . "followerId" , "UserSubscription" . "followeeId" , users . name as "followeeName"
FROM "UserSubscription"
INNER JOIN users ON users . id = "UserSubscription" . "followeeId"
WHERE "followeeId" = $1 AND $ { isPost ? '"postsSubscribedAt"' : '"commentsSubscribedAt"' } IS NOT NULL
AND NOT EXISTS ( SELECT 1 FROM "Mute" WHERE "Mute" . "muterId" = "UserSubscription" . "followerId" AND "Mute" . "mutedId" = $1 )
` , Number(item.userId))
2023-11-05 21:06:38 +00:00
const subType = isPost ? 'POST' : 'COMMENT'
const tag = ` FOLLOW- ${ item . userId } - ${ subType } `
2024-05-12 18:55:56 +00:00
await Promise . allSettled ( userSubsExcludingMutes . map ( ( { followerId , followeeName } ) => sendUserNotification ( followerId , {
title : ` @ ${ followeeName } ${ isPost ? 'created a post' : 'replied to a post' } ` ,
2023-09-26 20:27:55 +00:00
body : isPost ? item . title : item . text ,
item ,
2024-05-12 18:55:56 +00:00
data : { followeeName , subType } ,
2023-11-05 21:06:38 +00:00
tag
2023-09-26 20:27:55 +00:00
} ) ) )
} catch ( err ) {
console . error ( err )
}
}
2024-02-23 15:12:49 +00:00
export const notifyTerritorySubscribers = async ( { models , item } ) => {
try {
const isPost = ! ! item . title
const { subName } = item
// only notify on posts in subs
if ( ! isPost || ! subName ) return
2024-05-12 18:55:56 +00:00
const territorySubsExcludingMuted = await models . $queryRawUnsafe ( `
SELECT "userId" FROM "SubSubscription"
WHERE "subName" = $1
AND NOT EXISTS ( SELECT 1 FROM "Mute" m WHERE m . "muterId" = "SubSubscription" . "userId" AND m . "mutedId" = $2 )
` , subName, Number(item.userId))
2024-02-23 15:12:49 +00:00
const author = await models . user . findUnique ( { where : { id : item . userId } } )
const tag = ` TERRITORY_POST- ${ subName } `
await Promise . allSettled (
2024-05-12 18:55:56 +00:00
territorySubsExcludingMuted
2024-02-23 15:12:49 +00:00
// don't send push notification to author itself
. filter ( ( { userId } ) => userId !== author . id )
. map ( ( { userId } ) =>
sendUserNotification ( userId , {
title : ` @ ${ author . name } created a post in ~ ${ subName } ` ,
body : item . title ,
item ,
data : { subName } ,
tag
} ) ) )
} catch ( err ) {
console . error ( err )
}
}
2024-07-01 20:51:59 +00:00
export const notifyItemParents = async ( { models , item } ) => {
2023-09-26 20:27:55 +00:00
try {
2024-07-01 20:51:59 +00:00
const user = await models . user . findUnique ( { where : { id : item . userId } } )
2023-09-26 20:27:55 +00:00
const parents = await models . $queryRawUnsafe (
2023-12-20 02:02:48 +00:00
'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2 ' +
'AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = p."userId" AND m."mutedId" = $2)' ,
2023-09-26 20:27:55 +00:00
Number ( item . parentId ) , Number ( user . id ) )
Promise . allSettled (
parents . map ( ( { userId } ) => sendUserNotification ( userId , {
title : ` @ ${ user . name } replied to you ` ,
body : item . text ,
item ,
tag : 'REPLY'
} ) )
)
} catch ( err ) {
console . error ( err )
}
}
2024-07-01 20:51:59 +00:00
export const notifyZapped = async ( { models , item } ) => {
2023-09-26 20:27:55 +00:00
try {
2024-07-01 20:51:59 +00:00
const forwards = await models . itemForward . findMany ( { where : { itemId : item . id } } )
2023-09-26 20:27:55 +00:00
const userPromises = forwards . map ( fwd => models . user . findUnique ( { where : { id : fwd . userId } } ) )
const userResults = await Promise . allSettled ( userPromises )
const mappedForwards = forwards . map ( ( fwd , index ) => ( { ... fwd , user : userResults [ index ] . value ? ? null } ) )
let forwardedSats = 0
let forwardedUsers = ''
if ( mappedForwards . length ) {
2024-07-01 20:51:59 +00:00
forwardedSats = Math . floor ( msatsToSats ( item . msats ) * mappedForwards . map ( fwd => fwd . pct ) . reduce ( ( sum , cur ) => sum + cur ) / 100 )
2023-09-26 20:27:55 +00:00
forwardedUsers = mappedForwards . map ( fwd => ` @ ${ fwd . user . name } ` ) . join ( ', ' )
}
let notificationTitle
2024-07-01 20:51:59 +00:00
if ( item . title ) {
2023-09-26 20:27:55 +00:00
if ( forwards . length > 0 ) {
notificationTitle = ` your post forwarded ${ numWithUnits ( forwardedSats ) } to ${ forwardedUsers } `
} else {
2024-07-01 20:51:59 +00:00
notificationTitle = ` your post stacked ${ numWithUnits ( msatsToSats ( item . msats ) ) } `
2023-09-26 20:27:55 +00:00
}
} else {
if ( forwards . length > 0 ) {
// I don't think this case is possible
notificationTitle = ` your reply forwarded ${ numWithUnits ( forwardedSats ) } to ${ forwardedUsers } `
} else {
2024-07-01 20:51:59 +00:00
notificationTitle = ` your reply stacked ${ numWithUnits ( msatsToSats ( item . msats ) ) } `
2023-09-26 20:27:55 +00:00
}
}
2024-07-01 20:51:59 +00:00
await sendUserNotification ( item . userId , {
2023-09-26 20:27:55 +00:00
title : notificationTitle ,
2024-07-01 20:51:59 +00:00
body : item . title ? item . title : item . text ,
item ,
tag : ` TIP- ${ item . id } `
2023-09-26 20:27:55 +00:00
} )
// send push notifications to forwarded recipients
if ( mappedForwards . length ) {
await Promise . allSettled ( mappedForwards . map ( forward => sendUserNotification ( forward . user . id , {
2024-07-01 20:51:59 +00:00
title : ` you were forwarded ${ numWithUnits ( Math . round ( msatsToSats ( item . msats ) * forward . pct / 100 ) ) } ` ,
body : item . title ? ? item . text ,
item ,
tag : ` FORWARDEDTIP- ${ item . id } `
2023-09-26 20:27:55 +00:00
} ) ) )
}
} catch ( err ) {
console . error ( err )
}
}
2024-03-05 19:56:02 +00:00
2024-05-12 18:55:56 +00:00
export const notifyMention = async ( { models , userId , item } ) => {
2024-03-19 22:43:04 +00:00
try {
2024-05-12 18:55:56 +00:00
const muted = await isMuted ( { models , muterId : userId , mutedId : item . userId } )
if ( ! muted ) {
await sendUserNotification ( userId , {
title : 'you were mentioned' ,
body : item . text ,
item ,
tag : 'MENTION'
} )
}
2024-03-19 22:43:04 +00:00
} catch ( err ) {
console . error ( err )
}
}
2024-06-03 17:12:42 +00:00
export const notifyItemMention = async ( { models , referrerItem , refereeItem } ) => {
try {
const muted = await isMuted ( { models , muterId : refereeItem . userId , mutedId : referrerItem . userId } )
if ( ! muted ) {
const referrer = await models . user . findUnique ( { where : { id : referrerItem . userId } } )
// replace full links to #<id> syntax as rendered on site
const body = referrerItem . text . replace ( new RegExp ( ` ${ process . env . NEXT _PUBLIC _URL } /items/( \\ d+) ` , 'gi' ) , '#$1' )
await sendUserNotification ( refereeItem . userId , {
title : ` @ ${ referrer . name } mentioned one of your items ` ,
body ,
item : referrerItem ,
tag : 'ITEM_MENTION'
} )
}
} catch ( err ) {
console . error ( err )
}
}
2024-03-19 22:43:04 +00:00
export const notifyReferral = async ( userId ) => {
try {
2024-03-20 00:37:31 +00:00
await sendUserNotification ( userId , { title : 'someone joined via one of your referral links' , tag : 'REFERRAL' } )
} catch ( err ) {
console . error ( err )
}
}
export const notifyInvite = async ( userId ) => {
try {
await sendUserNotification ( userId , { title : 'your invite has been redeemed' , tag : 'INVITE' } )
2024-03-19 22:43:04 +00:00
} catch ( err ) {
console . error ( err )
}
}
2024-03-05 19:56:02 +00:00
export const notifyTerritoryTransfer = async ( { models , sub , to } ) => {
try {
await sendUserNotification ( to . id , {
title : ` ~ ${ sub . name } was transferred to you ` ,
tag : ` TERRITORY_TRANSFER- ${ sub . name } `
} )
} catch ( err ) {
console . error ( err )
}
}
2024-03-19 22:43:04 +00:00
export async function notifyEarner ( userId , earnings ) {
const fmt = msats => numWithUnits ( msatsToSats ( msats , { abbreviate : false } ) )
const title = ` you stacked ${ fmt ( earnings . msats ) } in rewards `
const tag = 'EARN'
let body = ''
if ( earnings . POST ) body += ` # ${ earnings . POST . bestRank } among posts with ${ fmt ( earnings . POST . msats ) } in total \n `
if ( earnings . COMMENT ) body += ` # ${ earnings . COMMENT . bestRank } among comments with ${ fmt ( earnings . COMMENT . msats ) } in total \n `
if ( earnings . TIP _POST ) body += ` # ${ earnings . TIP _POST . bestRank } in post zapping with ${ fmt ( earnings . TIP _POST . msats ) } in total \n `
if ( earnings . TIP _COMMENT ) body += ` # ${ earnings . TIP _COMMENT . bestRank } in comment zapping with ${ fmt ( earnings . TIP _COMMENT . msats ) } in total `
try {
await sendUserNotification ( userId , { title , tag , body } )
} catch ( err ) {
console . error ( err )
}
}
export async function notifyDeposit ( userId , invoice ) {
try {
await sendUserNotification ( userId , {
2024-03-25 23:53:49 +00:00
title : ` ${ numWithUnits ( msatsToSats ( invoice . received _mtokens ) , { abbreviate : false , unitSingular : 'sat was' , unitPlural : 'sats were' } )} deposited in your account ` ,
2024-03-19 22:43:04 +00:00
body : invoice . comment || undefined ,
tag : 'DEPOSIT' ,
data : { sats : msatsToSats ( invoice . received _mtokens ) }
} )
} catch ( err ) {
console . error ( err )
}
}
2024-03-25 23:47:23 +00:00
export async function notifyWithdrawal ( userId , wdrwl ) {
try {
await sendUserNotification ( userId , {
2024-03-25 23:53:49 +00:00
title : ` ${ numWithUnits ( msatsToSats ( wdrwl . payment . mtokens ) , { abbreviate : false , unitSingular : 'sat was' , unitPlural : 'sats were' } )} withdrawn from your account ` ,
2024-03-25 23:47:23 +00:00
tag : 'WITHDRAWAL' ,
data : { sats : msatsToSats ( wdrwl . payment . mtokens ) }
} )
} catch ( err ) {
console . error ( err )
}
}
2024-03-19 22:43:04 +00:00
export async function notifyNewStreak ( userId , streak ) {
const index = streak . id % FOUND _BLURBS . length
const blurb = FOUND _BLURBS [ index ]
try {
await sendUserNotification ( userId , {
title : 'you found a cowboy hat' ,
body : blurb ,
tag : 'STREAK-FOUND'
2024-03-20 00:37:31 +00:00
} )
2024-03-19 22:43:04 +00:00
} catch ( err ) {
console . error ( err )
}
}
export async function notifyStreakLost ( userId , streak ) {
const index = streak . id % LOST _BLURBS . length
const blurb = LOST _BLURBS [ index ]
try {
await sendUserNotification ( userId , {
title : 'you lost your cowboy hat' ,
body : blurb ,
tag : 'STREAK-LOST'
} )
} catch ( err ) {
console . error ( err )
}
}
2024-05-19 20:52:02 +00:00
export async function notifyReminder ( { userId , item , itemId } ) {
try {
await sendUserNotification ( userId , {
title : 'this is your requested reminder' ,
body : ` you asked to be reminded of this ${ item ? item . title ? 'post' : 'comment' : 'item' } ` ,
tag : ` REMIND-ITEM- ${ item ? . id ? ? itemId } ` ,
item : item ? ? { id : itemId }
} )
} catch ( err ) {
console . error ( err )
}
}