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 # 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) } }