More push notification types (#530)

* Add push notifications for referrals

* Add push notifications for daily rewards

* Add push notifications for deposits

* Add push notifications for earning cowboy hats

* Use streak id to synchronize blurb

* Fix usage of magic number for blurbs

* Fix missing catch

* Add push notification for losing cowboy hats

* Fix null in deposit push notification

* Add push notification for invites

* Don't replace streak push notifications

* Fix missing unit in daily reward push notification title

* Attach sats to payload options instead of parsing title

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
ekzyis 2023-10-05 01:20:52 +02:00 committed by GitHub
parent b3e8280d76
commit 425220d8cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 96 additions and 32 deletions

View File

@ -37,7 +37,12 @@ const createUserFilter = (tag) => {
REPLY: 'noteAllDescendants', REPLY: 'noteAllDescendants',
MENTION: 'noteMentions', MENTION: 'noteMentions',
TIP: 'noteItemSats', TIP: 'noteItemSats',
FORWARDEDTIP: 'noteForwardedSats' FORWARDEDTIP: 'noteForwardedSats',
REFERRAL: 'noteInvites',
INVITE: 'noteInvites',
EARN: 'noteEarning',
DEPOSIT: 'noteDeposits',
STREAK: 'noteCowboyHat'
} }
const key = tagMap[tag.split('-')[0]] const key = tagMap[tag.split('-')[0]]
return key ? { user: { [key]: true } } : undefined return key ? { user: { [key]: true } } : undefined

View File

@ -11,7 +11,7 @@ import { dayMonthYear, timeSince } from '../lib/time'
import Link from 'next/link' import Link from 'next/link'
import Check from '../svgs/check-double-line.svg' import Check from '../svgs/check-double-line.svg'
import HandCoin from '../svgs/hand-coin-fill.svg' import HandCoin from '../svgs/hand-coin-fill.svg'
import { COMMENT_DEPTH_LIMIT } from '../lib/constants' import { COMMENT_DEPTH_LIMIT, LOST_BLURBS, FOUND_BLURBS } from '../lib/constants'
import CowboyHatIcon from '../svgs/cowboy.svg' import CowboyHatIcon from '../svgs/cowboy.svg'
import BaldIcon from '../svgs/bald.svg' import BaldIcon from '../svgs/bald.svg'
import { RootProvider } from './root' import { RootProvider } from './root'
@ -122,25 +122,7 @@ const defaultOnClick = n => {
function Streak ({ n }) { function Streak ({ n }) {
function blurb (n) { function blurb (n) {
const index = Number(n.id) % 6 const index = Number(n.id) % Math.min(FOUND_BLURBS.length, LOST_BLURBS.length)
const FOUND_BLURBS = [
'The harsh frontier is no place for the unprepared. This hat will protect you from the sun, dust, and other elements Mother Nature throws your way.',
'A cowboy is nothing without a cowboy hat. Take good care of it, and it will protect you from the sun, dust, and other elements on your journey.',
"This is not just a hat, it's a matter of survival. Take care of this essential tool, and it will shield you from the scorching sun and the elements.",
"A cowboy hat isn't just a fashion statement. It's your last defense against the unforgiving elements of the Wild West. Hang onto it tight.",
"A good cowboy hat is worth its weight in gold, shielding you from the sun, wind, and dust of the western frontier. Don't lose it.",
'Your cowboy hat is the key to your survival in the wild west. Treat it with respect and it will protect you from the elements.'
]
const LOST_BLURBS = [
'your cowboy hat was taken by the wind storm that blew in from the west. No worries, a true cowboy always finds another hat.',
"you left your trusty cowboy hat in the saloon before leaving town. You'll need a replacement for the long journey west.",
'you lost your cowboy hat in a wild shoot-out on the outskirts of town. Tough luck, tIme to start searching for another one.',
'you ran out of food and had to trade your hat for supplies. Better start looking for another hat.',
"your hat was stolen by a mischievous prairie dog. You won't catch the dog, but you can always find another hat.",
'you lost your hat while crossing the river on your journey west. Maybe you can find a replacement hat in the next town.'
]
if (n.days) { if (n.days) {
return `After ${numWithUnits(n.days, { return `After ${numWithUnits(n.days, {
abbreviate: false, abbreviate: false,

View File

@ -52,3 +52,20 @@ export const ANON_COMMENT_FEE = 100
export const SSR = typeof window === 'undefined' export const SSR = typeof window === 'undefined'
export const MAX_FORWARDS = 5 export const MAX_FORWARDS = 5
export const LNURLP_COMMENT_MAX_LENGTH = 1000 export const LNURLP_COMMENT_MAX_LENGTH = 1000
export const FOUND_BLURBS = [
'The harsh frontier is no place for the unprepared. This hat will protect you from the sun, dust, and other elements Mother Nature throws your way.',
'A cowboy is nothing without a cowboy hat. Take good care of it, and it will protect you from the sun, dust, and other elements on your journey.',
"This is not just a hat, it's a matter of survival. Take care of this essential tool, and it will shield you from the scorching sun and the elements.",
"A cowboy hat isn't just a fashion statement. It's your last defense against the unforgiving elements of the Wild West. Hang onto it tight.",
"A good cowboy hat is worth its weight in gold, shielding you from the sun, wind, and dust of the western frontier. Don't lose it.",
'Your cowboy hat is the key to your survival in the wild west. Treat it with respect and it will protect you from the elements.'
]
export const LOST_BLURBS = [
'your cowboy hat was taken by the wind storm that blew in from the west. No worries, a true cowboy always finds another hat.',
"you left your trusty cowboy hat in the saloon before leaving town. You'll need a replacement for the long journey west.",
'you lost your cowboy hat in a wild shoot-out on the outskirts of town. Tough luck, tIme to start searching for another one.',
'you ran out of food and had to trade your hat for supplies. Better start looking for another hat.',
"your hat was stolen by a mischievous prairie dog. You won't catch the dog, but you can always find another hat.",
'you lost your hat while crossing the river on your journey west. Maybe you can find a replacement hat in the next town.'
]

View File

@ -11,6 +11,7 @@ import { decode, getToken } from 'next-auth/jwt'
import { NodeNextRequest } from 'next/dist/server/base-http/node' import { NodeNextRequest } from 'next/dist/server/base-http/node'
import jose1 from 'jose1' import jose1 from 'jose1'
import { schnorr } from '@noble/curves/secp256k1' import { schnorr } from '@noble/curves/secp256k1'
import { sendUserNotification } from '../../../api/webPush'
function getCallbacks (req) { function getCallbacks (req) {
return { return {
@ -42,6 +43,7 @@ function getCallbacks (req) {
const referrer = await prisma.user.findUnique({ where: { name: req.cookies.sn_referrer } }) const referrer = await prisma.user.findUnique({ where: { name: req.cookies.sn_referrer } })
if (referrer) { if (referrer) {
await prisma.user.update({ where: { id: user.id }, data: { referrerId: referrer.id } }) await prisma.user.update({ where: { id: user.id }, data: { referrerId: referrer.id } })
sendUserNotification(referrer.id, { title: 'someone joined via one of your referral links', tag: 'REFERRAL' }).catch(console.error)
} }
} }

View File

@ -9,6 +9,7 @@ import getSSRApolloClient from '../../api/ssrApollo'
import Link from 'next/link' import Link from 'next/link'
import { CenterLayout } from '../../components/layout' import { CenterLayout } from '../../components/layout'
import { getAuthOptions } from '../api/auth/[...nextauth]' import { getAuthOptions } from '../api/auth/[...nextauth]'
import { sendUserNotification } from '../../api/webPush'
export async function getServerSideProps ({ req, res, query: { id, error = null } }) { export async function getServerSideProps ({ req, res, query: { id, error = null } }) {
const session = await getServerSession(req, res, getAuthOptions(req)) const session = await getServerSession(req, res, getAuthOptions(req))
@ -36,6 +37,8 @@ export async function getServerSideProps ({ req, res, query: { id, error = null
// catch any errors and just ignore them for now // catch any errors and just ignore them for now
await serialize(models, await serialize(models,
models.$queryRawUnsafe('SELECT invite_drain($1::INTEGER, $2::INTEGER)', session.user.id, id)) models.$queryRawUnsafe('SELECT invite_drain($1::INTEGER, $2::INTEGER)', session.user.id, id))
const invite = await models.invite.findUnique({ where: { id } })
sendUserNotification(invite.userId, { title: 'your invite has been redeemed', tag: 'INVITE' }).catch(console.error)
} catch (e) { } catch (e) {
console.log(e) console.log(e)
} }

View File

@ -6,6 +6,7 @@ import { NetworkOnly } from 'workbox-strategies'
import { enable } from 'workbox-navigation-preload' import { enable } from 'workbox-navigation-preload'
import manifest from './precache-manifest.json' import manifest from './precache-manifest.json'
import ServiceWorkerStorage from 'serviceworker-storage' import ServiceWorkerStorage from 'serviceworker-storage'
import { numWithUnits } from '../lib/format'
// comment out to enable workbox console logs // comment out to enable workbox console logs
self.__WB_DISABLE_DEV_LOGS = true self.__WB_DISABLE_DEV_LOGS = true
@ -49,7 +50,8 @@ self.addEventListener('push', async function (event) {
if (!payload) return if (!payload) return
const { tag } = payload.options const { tag } = payload.options
event.waitUntil((async () => { event.waitUntil((async () => {
if (!['REPLY', 'MENTION'].includes(tag)) { // TIP and EARN notifications simply replace the previous notifications
if (!tag || ['TIP', 'EARN'].includes(tag.split('-')[0])) {
return self.registration.showNotification(payload.title, payload.options) return self.registration.showNotification(payload.title, payload.options)
} }
@ -66,15 +68,26 @@ self.addEventListener('push', async function (event) {
} }
const currentNotification = notifications[0] const currentNotification = notifications[0]
const amount = currentNotification.data?.amount ? currentNotification.data.amount + 1 : 2 const amount = currentNotification.data?.amount ? currentNotification.data.amount + 1 : 2
let title = '' let newTitle = ''
const data = {}
if (tag === 'REPLY') { if (tag === 'REPLY') {
title = `You have ${amount} new replies` newTitle = `You have ${amount} new replies`
} else if (tag === 'MENTION') { } else if (tag === 'MENTION') {
title = `You were mentioned ${amount} times` newTitle = `You were mentioned ${amount} times`
} else if (tag === 'REFERRAL') {
newTitle = `${amount} stackers joined via your referral links`
} else if (tag === 'INVITE') {
newTitle = `your invite has been redeemed by ${amount} stackers`
} else if (tag === 'DEPOSIT') {
const currentSats = currentNotification.data.sats
const incomingSats = payload.options.data.sats
const newSats = currentSats + incomingSats
data.sats = newSats
newTitle = `${numWithUnits(newSats, { abbreviate: false })} were deposited in your account`
} }
currentNotification.close() currentNotification.close()
const { icon } = currentNotification const { icon } = currentNotification
return self.registration.showNotification(title, { icon, tag, data: { url: '/notifications', amount } }) return self.registration.showNotification(newTitle, { icon, tag, data: { url: '/notifications', amount, ...data } })
})()) })())
}) })

View File

@ -1,5 +1,7 @@
import serialize from '../api/resolvers/serial.js' import serialize from '../api/resolvers/serial.js'
import { sendUserNotification } from '../api/webPush/index.js'
import { ANON_USER_ID } from '../lib/constants.js' import { ANON_USER_ID } from '../lib/constants.js'
import { msatsToSats, numWithUnits } from '../lib/format.js'
const ITEM_EACH_REWARD = 4.0 const ITEM_EACH_REWARD = 4.0
const UPVOTE_EACH_REWARD = 4.0 const UPVOTE_EACH_REWARD = 4.0
@ -160,6 +162,10 @@ export function earn ({ models }) {
await serialize(models, await serialize(models,
models.$executeRaw`SELECT earn(${earner.userId}::INTEGER, ${earnings}, models.$executeRaw`SELECT earn(${earner.userId}::INTEGER, ${earnings},
${now}::timestamp without time zone, ${earner.type}::"EarnType", ${earner.id}::INTEGER, ${earner.rank}::INTEGER)`) ${now}::timestamp without time zone, ${earner.type}::"EarnType", ${earner.id}::INTEGER, ${earner.rank}::INTEGER)`)
sendUserNotification(earner.userId, {
title: `you stacked ${numWithUnits(msatsToSats(earnings), { abbreviate: false })} in rewards`,
tag: 'EARN'
}).catch(console.error)
} }
}) })

View File

@ -1,3 +1,6 @@
import { sendUserNotification } from '../api/webPush'
import { FOUND_BLURBS, LOST_BLURBS } from '../lib/constants'
const STREAK_THRESHOLD = 100 const STREAK_THRESHOLD = 100
export function computeStreaks ({ models }) { export function computeStreaks ({ models }) {
@ -7,8 +10,8 @@ export function computeStreaks ({ models }) {
// get all eligible users in the last day // get all eligible users in the last day
// if the user doesn't have an active streak, add one // if the user doesn't have an active streak, add one
// if they have an active streak but didn't maintain it, end it // if they have an active streak but didn't maintain it, end it
await models.$executeRawUnsafe( const endingStreaks = await models.$queryRaw`
`WITH day_streaks (id) AS ( WITH day_streaks (id) AS (
SELECT "userId" SELECT "userId"
FROM FROM
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent ((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
@ -58,7 +61,20 @@ export function computeStreaks ({ models }) {
UPDATE "Streak" UPDATE "Streak"
SET "endedAt" = (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, updated_at = now_utc() SET "endedAt" = (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, updated_at = now_utc()
FROM ending_streaks FROM ending_streaks
WHERE ending_streaks.id = "Streak"."userId" AND "endedAt" IS NULL`) WHERE ending_streaks.id = "Streak"."userId" AND "endedAt" IS NULL
RETURNING "Streak".id, ending_streaks."id" AS "userId"`
Promise.allSettled(
endingStreaks.map(({ id, userId }) => {
const index = id % LOST_BLURBS.length
const blurb = LOST_BLURBS[index]
return sendUserNotification(userId, {
title: 'you lost your cowboy hat',
body: blurb,
tag: 'STREAK-LOST'
}).catch(console.error)
})
)
console.log('done computing streaks') console.log('done computing streaks')
} }
@ -69,7 +85,7 @@ export function checkStreak ({ models }) {
console.log('checking streak', id) console.log('checking streak', id)
// if user is actively streaking skip // if user is actively streaking skip
const streak = await models.streak.findFirst({ let streak = await models.streak.findFirst({
where: { where: {
userId: Number(id), userId: Number(id),
endedAt: null endedAt: null
@ -81,7 +97,7 @@ export function checkStreak ({ models }) {
return return
} }
await models.$executeRaw` [streak] = await models.$queryRaw`
WITH streak_started (id) AS ( WITH streak_started (id) AS (
SELECT "userId" SELECT "userId"
FROM FROM
@ -103,8 +119,20 @@ export function checkStreak ({ models }) {
) )
INSERT INTO "Streak" ("userId", "startedAt", created_at, updated_at) INSERT INTO "Streak" ("userId", "startedAt", created_at, updated_at)
SELECT id, (now() AT TIME ZONE 'America/Chicago')::date, now_utc(), now_utc() SELECT id, (now() AT TIME ZONE 'America/Chicago')::date, now_utc(), now_utc()
FROM streak_started` FROM streak_started
RETURNING "Streak".id`
console.log('done checking streak', id) console.log('done checking streak', id)
if (!streak) return
// new streak started for user
const index = streak.id % FOUND_BLURBS.length
const blurb = FOUND_BLURBS[index]
sendUserNotification(id, {
title: 'you found a cowboy hat',
body: blurb,
tag: 'STREAK-FOUND'
}).catch(console.error)
} }
} }

View File

@ -1,6 +1,8 @@
import serialize from '../api/resolvers/serial.js' import serialize from '../api/resolvers/serial.js'
import { getInvoice, getPayment, cancelHodlInvoice } from 'ln-service' import { getInvoice, getPayment, cancelHodlInvoice } from 'ln-service'
import { datePivot } from '../lib/time.js' import { datePivot } from '../lib/time.js'
import { sendUserNotification } from '../api/webPush/index.js'
import { msatsToSats, numWithUnits } from '../lib/format'
const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true } const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
@ -29,6 +31,12 @@ export function checkInvoice ({ boss, models, lnd }) {
// we manually confirm them when we settle them // we manually confirm them when we settle them
await serialize(models, await serialize(models,
models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`) models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`)
sendUserNotification(dbInv.userId, {
title: `${numWithUnits(msatsToSats(inv.received_mtokens), { abbreviate: false })} were deposited in your account`,
body: dbInv.comment || undefined,
tag: 'DEPOSIT',
data: { sats: msatsToSats(inv.received_mtokens) }
}).catch(console.error)
return boss.send('nip57', { hash }) return boss.send('nip57', { hash })
} }