407 lines
14 KiB
JavaScript
407 lines
14 KiB
JavaScript
import webPush from 'web-push'
|
|
import removeMd from 'remove-markdown'
|
|
import { COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants'
|
|
import { msatsToSats, numWithUnits } from './format'
|
|
import models from '@/api/models'
|
|
import { isMuted } from '@/lib/user'
|
|
|
|
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',
|
|
ITEM_MENTION: 'noteItemMentions',
|
|
TIP: 'noteItemSats',
|
|
FORWARDEDTIP: 'noteForwardedSats',
|
|
REFERRAL: 'noteInvites',
|
|
INVITE: 'noteInvites',
|
|
EARN: 'noteEarning',
|
|
DEPOSIT: 'noteDeposits',
|
|
WITHDRAWAL: 'noteWithdrawals',
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
|
|
async function sendUserNotification (userId, notification) {
|
|
try {
|
|
if (!userId) {
|
|
throw new Error('user id is required')
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
export const notifyUserSubscribers = async ({ models, item }) => {
|
|
try {
|
|
const isPost = !!item.title
|
|
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)
|
|
-- ignore subscription if user was already notified of item as a reply
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM "Reply"
|
|
INNER JOIN users follower ON follower.id = "UserSubscription"."followerId"
|
|
WHERE "Reply"."itemId" = $2
|
|
AND "Reply"."ancestorUserId" = follower.id
|
|
AND follower."noteAllDescendants"
|
|
)
|
|
`, Number(item.userId), Number(item.id))
|
|
const subType = isPost ? 'POST' : 'COMMENT'
|
|
const tag = `FOLLOW-${item.userId}-${subType}`
|
|
await Promise.allSettled(userSubsExcludingMutes.map(({ followerId, followeeName }) => sendUserNotification(followerId, {
|
|
title: `@${followeeName} ${isPost ? 'created a post' : 'replied to a post'}`,
|
|
body: isPost ? item.title : item.text,
|
|
item,
|
|
data: { followeeName, subType },
|
|
tag
|
|
})))
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
export const notifyTerritorySubscribers = async ({ models, item }) => {
|
|
try {
|
|
const isPost = !!item.title
|
|
const { subName } = item
|
|
|
|
// only notify on posts in subs
|
|
if (!isPost || !subName) return
|
|
|
|
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))
|
|
|
|
const author = await models.user.findUnique({ where: { id: item.userId } })
|
|
|
|
const tag = `TERRITORY_POST-${subName}`
|
|
await Promise.allSettled(
|
|
territorySubsExcludingMuted
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
export const notifyItemParents = async ({ models, item }) => {
|
|
try {
|
|
const user = await models.user.findUnique({ where: { id: item.userId } })
|
|
const parents = await models.$queryRawUnsafe(
|
|
'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)',
|
|
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)
|
|
}
|
|
}
|
|
|
|
export const notifyZapped = async ({ models, item }) => {
|
|
try {
|
|
const forwards = await models.itemForward.findMany({ where: { itemId: item.id } })
|
|
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) {
|
|
forwardedSats = Math.floor(msatsToSats(item.msats) * mappedForwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100)
|
|
forwardedUsers = mappedForwards.map(fwd => `@${fwd.user.name}`).join(', ')
|
|
}
|
|
let notificationTitle
|
|
if (item.title) {
|
|
if (forwards.length > 0) {
|
|
notificationTitle = `your post forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}`
|
|
} else {
|
|
notificationTitle = `your post stacked ${numWithUnits(msatsToSats(item.msats))}`
|
|
}
|
|
} else {
|
|
if (forwards.length > 0) {
|
|
// I don't think this case is possible
|
|
notificationTitle = `your reply forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}`
|
|
} else {
|
|
notificationTitle = `your reply stacked ${numWithUnits(msatsToSats(item.msats))}`
|
|
}
|
|
}
|
|
|
|
await sendUserNotification(item.userId, {
|
|
title: notificationTitle,
|
|
body: item.title ? item.title : item.text,
|
|
item,
|
|
tag: `TIP-${item.id}`
|
|
})
|
|
|
|
// send push notifications to forwarded recipients
|
|
if (mappedForwards.length) {
|
|
await Promise.allSettled(mappedForwards.map(forward => sendUserNotification(forward.user.id, {
|
|
title: `you were forwarded ${numWithUnits(Math.round(msatsToSats(item.msats) * forward.pct / 100))}`,
|
|
body: item.title ?? item.text,
|
|
item,
|
|
tag: `FORWARDEDTIP-${item.id}`
|
|
})))
|
|
}
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
export const notifyMention = async ({ models, userId, item }) => {
|
|
try {
|
|
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'
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
export const notifyReferral = async (userId) => {
|
|
try {
|
|
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' })
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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, {
|
|
title: `${numWithUnits(msatsToSats(invoice.received_mtokens), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account`,
|
|
body: invoice.comment || undefined,
|
|
tag: 'DEPOSIT',
|
|
data: { sats: msatsToSats(invoice.received_mtokens) }
|
|
})
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
export async function notifyWithdrawal (userId, wdrwl) {
|
|
try {
|
|
await sendUserNotification(userId, {
|
|
title: `${numWithUnits(msatsToSats(wdrwl.payment.mtokens), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`,
|
|
tag: 'WITHDRAWAL',
|
|
data: { sats: msatsToSats(wdrwl.payment.mtokens) }
|
|
})
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
export async function notifyNewStreak (userId, streak) {
|
|
const index = streak.id % FOUND_BLURBS[streak.type].length
|
|
const blurb = FOUND_BLURBS[streak.type][index]
|
|
|
|
try {
|
|
await sendUserNotification(userId, {
|
|
title: `you found a ${streak.type.toLowerCase().replace('_', ' ')}`,
|
|
body: blurb,
|
|
tag: `STREAK-FOUND-${streak.type}`
|
|
})
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
export async function notifyStreakLost (userId, streak) {
|
|
const index = streak.id % LOST_BLURBS[streak.type].length
|
|
const blurb = LOST_BLURBS[streak.type][index]
|
|
|
|
try {
|
|
await sendUserNotification(userId, {
|
|
title: `you lost your ${streak.type.toLowerCase().replace('_', ' ')}`,
|
|
body: blurb,
|
|
tag: `STREAK-LOST-${streak.type}`
|
|
})
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|