Merge branch 'master' into mailhog
This commit is contained in:
commit
2055ccaf41
4
.gitignore
vendored
4
.gitignore
vendored
@ -47,3 +47,7 @@ public/workbox-*.js*
|
|||||||
public/*-development.js
|
public/*-development.js
|
||||||
|
|
||||||
.cache_ggshield
|
.cache_ggshield
|
||||||
|
docker-compose.*.yml
|
||||||
|
*.sql
|
||||||
|
!/prisma/migrations/*/*.sql
|
||||||
|
!/docker/db/seed.sql
|
63
README.md
63
README.md
@ -42,25 +42,6 @@ Start the development environment
|
|||||||
$ ./sndev start
|
$ ./sndev start
|
||||||
```
|
```
|
||||||
|
|
||||||
By default all services will be run. If you want to exclude specific services from running, set `COMPOSE_PROFILES` to use one or more of `minimal|images|search|payments|email`. To only run mininal services without images, search, or payments:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ COMPOSE_PROFILES=minimal ./sndev start
|
|
||||||
```
|
|
||||||
|
|
||||||
Or, as I would recommend:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ export COMPOSE_PROFILES=minimal
|
|
||||||
$ ./sndev start
|
|
||||||
```
|
|
||||||
|
|
||||||
To run with images and payments services:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ COMPOSE_PROFILES=images,payments ./sndev start
|
|
||||||
```
|
|
||||||
|
|
||||||
View all available commands
|
View all available commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@ -113,12 +94,56 @@ COMMANDS
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Modifying services
|
||||||
|
|
||||||
|
#### Running specific services
|
||||||
|
|
||||||
|
By default all services will be run. If you want to exclude specific services from running, set `COMPOSE_PROFILES` to use one or more of `minimal|images|search|payments|email`. To only run mininal services without images, search, or payments:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ COMPOSE_PROFILES=minimal ./sndev start
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, as I would recommend:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ export COMPOSE_PROFILES=minimal
|
||||||
|
$ ./sndev start
|
||||||
|
```
|
||||||
|
|
||||||
|
To run with images and payments services:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ COMPOSE_PROFILES=images,payments ./sndev start
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Merging compose files
|
||||||
|
|
||||||
|
By default `sndev start` will merge `docker-compose.yml` with `docker-compose.override.yml`. Specify any overrides you want to merge with `docker-compose.override.yml`.
|
||||||
|
|
||||||
|
For example, if you want to replace the db seed with a custom seed file located in `docker/db/another.sql`, you'd create a `docker-compose.override.yml` file with the following:
|
||||||
|
|
||||||
|
```yml
|
||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
volumes:
|
||||||
|
- ./docker/db/another.sql:/docker-entrypoint-initdb.d/seed.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
You can read more about [docker compose override files](https://docs.docker.com/compose/multiple-compose-files/merge/).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
# Table of Contents
|
# Table of Contents
|
||||||
- [Getting started](#getting-started)
|
- [Getting started](#getting-started)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Usage](#usage)
|
- [Usage](#usage)
|
||||||
|
- [Modifying services](#modifying-services)
|
||||||
|
- [Running specific services](#running-specific-services)
|
||||||
|
- [Merging compose files](#merging-compose-files)
|
||||||
- [Contributing](#contributing)
|
- [Contributing](#contributing)
|
||||||
- [We pay bitcoin for contributions](#we-pay-bitcoin-for-contributions)
|
- [We pay bitcoin for contributions](#we-pay-bitcoin-for-contributions)
|
||||||
- [Pull request awards](#pull-request-awards)
|
- [Pull request awards](#pull-request-awards)
|
||||||
|
@ -78,14 +78,13 @@ export default {
|
|||||||
itemDrivenQueries.push(
|
itemDrivenQueries.push(
|
||||||
`SELECT "Item".*, "Item".created_at AS "sortTime", 'Reply' AS type
|
`SELECT "Item".*, "Item".created_at AS "sortTime", 'Reply' AS type
|
||||||
FROM "ThreadSubscription"
|
FROM "ThreadSubscription"
|
||||||
JOIN "Item" p ON "ThreadSubscription"."itemId" = p.id
|
JOIN "Reply" r ON "ThreadSubscription"."itemId" = r."ancestorId"
|
||||||
JOIN "Item" ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
|
JOIN "Item" ON r."itemId" = "Item".id
|
||||||
${whereClause(
|
${whereClause(
|
||||||
'"ThreadSubscription"."userId" = $1',
|
'"ThreadSubscription"."userId" = $1',
|
||||||
'"Item"."userId" <> $1',
|
'r.created_at >= "ThreadSubscription".created_at',
|
||||||
'"Item".created_at >= "ThreadSubscription".created_at',
|
'r.created_at < $2',
|
||||||
'"Item".created_at < $2',
|
...(meFull.noteAllDescendants ? [] : ['r.level = 1'])
|
||||||
'"Item"."parentId" IS NOT NULL'
|
|
||||||
)}
|
)}
|
||||||
ORDER BY "sortTime" DESC
|
ORDER BY "sortTime" DESC
|
||||||
LIMIT ${LIMIT}`
|
LIMIT ${LIMIT}`
|
||||||
@ -185,15 +184,11 @@ export default {
|
|||||||
|
|
||||||
if (meFull.noteItemSats) {
|
if (meFull.noteItemSats) {
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT "Item".id::TEXT, MAX("ItemAct".created_at) AS "sortTime",
|
`(SELECT "Item".id::TEXT, "Item"."lastZapAt" AS "sortTime",
|
||||||
MAX("Item".msats/1000) as "earnedSats", 'Votification' AS type
|
"Item".msats/1000 as "earnedSats", 'Votification' AS type
|
||||||
FROM "Item"
|
FROM "Item"
|
||||||
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
|
WHERE "Item"."userId" = $1
|
||||||
WHERE "ItemAct"."userId" <> $1
|
AND "Item"."lastZapAt" < $2
|
||||||
AND "ItemAct".created_at < $2
|
|
||||||
AND "ItemAct".act IN ('TIP', 'FEE')
|
|
||||||
AND "Item"."userId" = $1
|
|
||||||
GROUP BY "Item".id
|
|
||||||
ORDER BY "sortTime" DESC
|
ORDER BY "sortTime" DESC
|
||||||
LIMIT ${LIMIT})`
|
LIMIT ${LIMIT})`
|
||||||
)
|
)
|
||||||
@ -201,16 +196,12 @@ export default {
|
|||||||
|
|
||||||
if (meFull.noteForwardedSats) {
|
if (meFull.noteForwardedSats) {
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT "Item".id::TEXT, MAX("ItemAct".created_at) AS "sortTime",
|
`(SELECT "Item".id::TEXT, "Item"."lastZapAt" AS "sortTime",
|
||||||
MAX("Item".msats / 1000 * "ItemForward".pct / 100) as "earnedSats", 'ForwardedVotification' AS type
|
("Item".msats / 1000 * "ItemForward".pct / 100) as "earnedSats", 'ForwardedVotification' AS type
|
||||||
FROM "Item"
|
FROM "Item"
|
||||||
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
|
|
||||||
JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "ItemForward"."userId" = $1
|
JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "ItemForward"."userId" = $1
|
||||||
WHERE "ItemAct"."userId" <> $1
|
WHERE "Item"."userId" <> $1
|
||||||
AND "Item"."userId" <> $1
|
AND "Item"."lastZapAt" < $2
|
||||||
AND "ItemAct".created_at < $2
|
|
||||||
AND "ItemAct".act IN ('TIP')
|
|
||||||
GROUP BY "Item".id
|
|
||||||
ORDER BY "sortTime" DESC
|
ORDER BY "sortTime" DESC
|
||||||
LIMIT ${LIMIT})`
|
LIMIT ${LIMIT})`
|
||||||
)
|
)
|
||||||
@ -299,23 +290,11 @@ export default {
|
|||||||
LIMIT ${LIMIT})`
|
LIMIT ${LIMIT})`
|
||||||
)
|
)
|
||||||
|
|
||||||
// we do all this crazy subquery stuff to make 'reward' islands
|
|
||||||
const notifications = await models.$queryRawUnsafe(
|
const notifications = await models.$queryRawUnsafe(
|
||||||
`SELECT MAX(id) AS id, MAX("sortTime") AS "sortTime", sum("earnedSats") AS "earnedSats", type,
|
`SELECT id, "sortTime", "earnedSats", type,
|
||||||
MIN("sortTime") AS "minSortTime"
|
"sortTime" AS "minSortTime"
|
||||||
FROM
|
FROM
|
||||||
(SELECT *,
|
(${queries.join(' UNION ALL ')}) u
|
||||||
CASE
|
|
||||||
WHEN type = 'Earn' THEN
|
|
||||||
ROW_NUMBER() OVER(ORDER BY "sortTime" DESC) -
|
|
||||||
ROW_NUMBER() OVER(PARTITION BY type = 'Earn' ORDER BY "sortTime" DESC)
|
|
||||||
ELSE
|
|
||||||
ROW_NUMBER() OVER(ORDER BY "sortTime" DESC)
|
|
||||||
END as island
|
|
||||||
FROM
|
|
||||||
(${queries.join(' UNION ALL ')}) u
|
|
||||||
) sub
|
|
||||||
GROUP BY type, island
|
|
||||||
ORDER BY "sortTime" DESC
|
ORDER BY "sortTime" DESC
|
||||||
LIMIT ${LIMIT}`, me.id, decodedCursor.time)
|
LIMIT ${LIMIT}`, me.id, decodedCursor.time)
|
||||||
|
|
||||||
|
@ -308,7 +308,7 @@ export default {
|
|||||||
|
|
||||||
const [, updatedSub] = await models.$transaction([
|
const [, updatedSub] = await models.$transaction([
|
||||||
models.territoryTransfer.create({ data: { subName, oldUserId: me.id, newUserId: user.id } }),
|
models.territoryTransfer.create({ data: { subName, oldUserId: me.id, newUserId: user.id } }),
|
||||||
models.sub.update({ where: { name: subName }, data: { userId: user.id } })
|
models.sub.update({ where: { name: subName }, data: { userId: user.id, billingAutoRenew: false } })
|
||||||
])
|
])
|
||||||
|
|
||||||
notifyTerritoryTransfer({ models, sub, to: user })
|
notifyTerritoryTransfer({ models, sub, to: user })
|
||||||
@ -367,7 +367,7 @@ export default {
|
|||||||
].filter(q => !!q),
|
].filter(q => !!q),
|
||||||
{ models, lnd, hash, hmac, me, enforceFee: billingCost })
|
{ models, lnd, hash, hmac, me, enforceFee: billingCost })
|
||||||
|
|
||||||
if (oldSub.userId !== me.id) notifyTerritoryTransfer({ models, sub: newSub, to: me.id })
|
if (oldSub.userId !== me.id) notifyTerritoryTransfer({ models, sub: newSub, to: me })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Sub: {
|
Sub: {
|
||||||
@ -400,14 +400,15 @@ export default {
|
|||||||
async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
|
async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
|
||||||
const { billingType } = data
|
const { billingType } = data
|
||||||
let billingCost = TERRITORY_COST_MONTHLY
|
let billingCost = TERRITORY_COST_MONTHLY
|
||||||
let billPaidUntil = datePivot(new Date(), { months: 1 })
|
const billedLastAt = new Date()
|
||||||
|
let billPaidUntil = datePivot(billedLastAt, { months: 1 })
|
||||||
|
|
||||||
if (billingType === 'ONCE') {
|
if (billingType === 'ONCE') {
|
||||||
billingCost = TERRITORY_COST_ONCE
|
billingCost = TERRITORY_COST_ONCE
|
||||||
billPaidUntil = null
|
billPaidUntil = null
|
||||||
} else if (billingType === 'YEARLY') {
|
} else if (billingType === 'YEARLY') {
|
||||||
billingCost = TERRITORY_COST_YEARLY
|
billingCost = TERRITORY_COST_YEARLY
|
||||||
billPaidUntil = datePivot(new Date(), { years: 1 })
|
billPaidUntil = datePivot(billedLastAt, { years: 1 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const cost = BigInt(1000) * BigInt(billingCost)
|
const cost = BigInt(1000) * BigInt(billingCost)
|
||||||
@ -429,6 +430,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
|
|||||||
models.sub.create({
|
models.sub.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
|
billedLastAt,
|
||||||
billPaidUntil,
|
billPaidUntil,
|
||||||
billingCost,
|
billingCost,
|
||||||
rankingType: 'WOT',
|
rankingType: 'WOT',
|
||||||
|
@ -57,6 +57,7 @@ export async function topUsers (parent, { cursor, when, by, from, to, limit = LI
|
|||||||
|
|
||||||
let column
|
let column
|
||||||
switch (by) {
|
switch (by) {
|
||||||
|
case 'spending':
|
||||||
case 'spent': column = 'spent'; break
|
case 'spent': column = 'spent'; break
|
||||||
case 'posts': column = 'nposts'; break
|
case 'posts': column = 'nposts'; break
|
||||||
case 'comments': column = 'ncomments'; break
|
case 'comments': column = 'ncomments'; break
|
||||||
@ -219,12 +220,8 @@ export default {
|
|||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM "Item"
|
FROM "Item"
|
||||||
JOIN "ItemAct" ON
|
WHERE "Item"."lastZapAt" > $2
|
||||||
"ItemAct"."itemId" = "Item".id
|
AND "Item"."userId" = $1)`, me.id, lastChecked)
|
||||||
AND "ItemAct"."userId" <> "Item"."userId"
|
|
||||||
WHERE "ItemAct".created_at > $2
|
|
||||||
AND "Item"."userId" = $1
|
|
||||||
AND "ItemAct".act = 'TIP')`, me.id, lastChecked)
|
|
||||||
if (newSats.exists) {
|
if (newSats.exists) {
|
||||||
foundNotes()
|
foundNotes()
|
||||||
return true
|
return true
|
||||||
@ -236,15 +233,15 @@ export default {
|
|||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM "ThreadSubscription"
|
FROM "ThreadSubscription"
|
||||||
JOIN "Item" p ON "ThreadSubscription"."itemId" = p.id
|
JOIN "Reply" r ON "ThreadSubscription"."itemId" = r."ancestorId"
|
||||||
JOIN "Item" ON ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
|
JOIN "Item" ON r."itemId" = "Item".id
|
||||||
${whereClause(
|
${whereClause(
|
||||||
'"ThreadSubscription"."userId" = $1',
|
'"ThreadSubscription"."userId" = $1',
|
||||||
'"Item".created_at > $2',
|
'r.created_at > $2',
|
||||||
'"Item".created_at >= "ThreadSubscription".created_at',
|
'r.created_at >= "ThreadSubscription".created_at',
|
||||||
'"Item"."userId" <> $1',
|
|
||||||
await filterClause(me, models),
|
await filterClause(me, models),
|
||||||
muteClause(me)
|
muteClause(me),
|
||||||
|
...(user.noteAllDescendants ? [] : ['r.level = 1'])
|
||||||
)})`, me.id, lastChecked)
|
)})`, me.id, lastChecked)
|
||||||
if (newThreadSubReply.exists) {
|
if (newThreadSubReply.exists) {
|
||||||
foundNotes()
|
foundNotes()
|
||||||
@ -295,15 +292,11 @@ export default {
|
|||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM "Item"
|
FROM "Item"
|
||||||
JOIN "ItemAct" ON
|
|
||||||
"ItemAct"."itemId" = "Item".id
|
|
||||||
AND "ItemAct"."userId" <> "Item"."userId"
|
|
||||||
JOIN "ItemForward" ON
|
JOIN "ItemForward" ON
|
||||||
"ItemForward"."itemId" = "Item".id
|
"ItemForward"."itemId" = "Item".id
|
||||||
AND "ItemForward"."userId" = $1
|
AND "ItemForward"."userId" = $1
|
||||||
WHERE "ItemAct".created_at > $2
|
WHERE "Item"."lastZapAt" > $2
|
||||||
AND "Item"."userId" <> $1
|
AND "Item"."userId" <> $1)`, me.id, lastChecked)
|
||||||
AND "ItemAct".act = 'TIP')`, me.id, lastChecked)
|
|
||||||
if (newFwdSats.exists) {
|
if (newFwdSats.exists) {
|
||||||
foundNotes()
|
foundNotes()
|
||||||
return true
|
return true
|
||||||
|
@ -92,7 +92,7 @@ export default gql`
|
|||||||
nsfwMode: Boolean!
|
nsfwMode: Boolean!
|
||||||
tipDefault: Int!
|
tipDefault: Int!
|
||||||
turboTipping: Boolean!
|
turboTipping: Boolean!
|
||||||
zapUndos: Boolean!
|
zapUndos: Int
|
||||||
wildWestMode: Boolean!
|
wildWestMode: Boolean!
|
||||||
withdrawMaxFeeDefault: Int!
|
withdrawMaxFeeDefault: Int!
|
||||||
}
|
}
|
||||||
@ -157,7 +157,7 @@ export default gql`
|
|||||||
nsfwMode: Boolean!
|
nsfwMode: Boolean!
|
||||||
tipDefault: Int!
|
tipDefault: Int!
|
||||||
turboTipping: Boolean!
|
turboTipping: Boolean!
|
||||||
zapUndos: Boolean!
|
zapUndos: Int
|
||||||
wildWestMode: Boolean!
|
wildWestMode: Boolean!
|
||||||
withdrawMaxFeeDefault: Int!
|
withdrawMaxFeeDefault: Int!
|
||||||
autoWithdrawThreshold: Int
|
autoWithdrawThreshold: Int
|
||||||
|
@ -1,114 +0,0 @@
|
|||||||
import webPush from 'web-push'
|
|
||||||
import models from '@/api/models'
|
|
||||||
import { COMMENT_DEPTH_LIMIT } from '@/lib/constants'
|
|
||||||
import removeMd from 'remove-markdown'
|
|
||||||
|
|
||||||
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',
|
|
||||||
TIP: 'noteItemSats',
|
|
||||||
FORWARDEDTIP: 'noteForwardedSats',
|
|
||||||
REFERRAL: 'noteInvites',
|
|
||||||
INVITE: 'noteInvites',
|
|
||||||
EARN: 'noteEarning',
|
|
||||||
DEPOSIT: 'noteDeposits',
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendUserNotification (userId, notification) {
|
|
||||||
try {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -5,3 +5,12 @@ SatsAllDay,docs,#925,,,,,typo,100,weareallsatoshi@getalby.com,2024-03-16
|
|||||||
SatsAllDay,issue,#933,#928,medium,,,,25k,weareallsatoshi@getalby.com,2024-03-18
|
SatsAllDay,issue,#933,#928,medium,,,,25k,weareallsatoshi@getalby.com,2024-03-18
|
||||||
SatsAllDay,code review,#933,#928,medium,,,,25k,weareallsatoshi@getalby.com,2024-03-18
|
SatsAllDay,code review,#933,#928,medium,,,,25k,weareallsatoshi@getalby.com,2024-03-18
|
||||||
SatsAllDay,pr,#942,#941,good-first-issue,,,,20k,weareallsatoshi@getalby.com,2024-03-20
|
SatsAllDay,pr,#942,#941,good-first-issue,,,,20k,weareallsatoshi@getalby.com,2024-03-20
|
||||||
|
SatsAllDay,pr,#954,#926,easy,,,,100k,weareallsatoshi@getalby.com,2024-03-23
|
||||||
|
SatsAllDay,pr,#956,,good-first-issue,,,,22k,weareallsatoshi@getalby.com,2024-03-23
|
||||||
|
cointastical,issue,#960,#735,good-first-issue,,,,2k,cointastical@stacker.news,2024-03-24
|
||||||
|
SatsAllDay,pr,#960,#735,good-first-issue,,,,20k,weareallsatoshi@getalby.com,2024-03-24
|
||||||
|
cointastical,issue,,#932,,,,,10k,cointastical@stacker.news,2024-03-25
|
||||||
|
SatsAllDay,pr,#955,#901,good-first-issue,,,,20k,weareallsatoshi@getalby.com,2024-03-25
|
||||||
|
SatsAllDay,issue,#964,#959,easy,,,,10k,weareallsatoshi@getalby.com,2024-03-25
|
||||||
|
SatsAllDay,code review,#964,#959,easy,,,,10k,weareallsatoshi@getalby.com,2024-03-25
|
||||||
|
AustinKelsay,pr,#970,,,,,,20k,bitcoinplebdev@stacker.news,2024-03-25
|
||||||
|
|
19
components/charts-skeletons.js
Normal file
19
components/charts-skeletons.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export function GrowthPieChartSkeleton ({ height = '250px', minWidth = '200px' }) {
|
||||||
|
return <ChartSkeleton {...{ height, minWidth }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhenComposedChartSkeleton ({ height = '300px', minWidth = '300px' }) {
|
||||||
|
return <ChartSkeleton {...{ height, minWidth }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhenAreaChartSkeleton ({ height = '300px', minWidth = '300px' }) {
|
||||||
|
return <ChartSkeleton {...{ height, minWidth }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhenLineChartSkeleton ({ height = '300px', minWidth = '300px' }) {
|
||||||
|
return <ChartSkeleton {...{ height, minWidth }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartSkeleton (props) {
|
||||||
|
return <div className='mx-auto w-100 clouds' style={{ ...props }} />
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import Dropdown from 'react-bootstrap/Dropdown'
|
import Dropdown from 'react-bootstrap/Dropdown'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import ItemAct from './item-act'
|
import ItemAct, { zapUndosThresholdReached } from './item-act'
|
||||||
import AccordianItem from './accordian-item'
|
import AccordianItem from './accordian-item'
|
||||||
import Flag from '@/svgs/flag-fill.svg'
|
import Flag from '@/svgs/flag-fill.svg'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
@ -32,12 +32,11 @@ function DownZapper ({ id, As, children }) {
|
|||||||
try {
|
try {
|
||||||
showModal(onClose =>
|
showModal(onClose =>
|
||||||
<ItemAct
|
<ItemAct
|
||||||
onClose={() => {
|
onClose={(amount) => {
|
||||||
onClose()
|
onClose()
|
||||||
// undo prompt was toasted before closing modal if zap undos are enabled
|
// undo prompt was toasted before closing modal if zap undos are enabled
|
||||||
// so an additional success toast would be confusing
|
// so an additional success toast would be confusing
|
||||||
const zapUndosEnabled = me && me?.privates?.zapUndos
|
if (!zapUndosThresholdReached(me, amount)) toaster.success('item downzapped')
|
||||||
if (!zapUndosEnabled) toaster.success('item downzapped')
|
|
||||||
}} itemId={id} down
|
}} itemId={id} down
|
||||||
>
|
>
|
||||||
<AccordianItem
|
<AccordianItem
|
||||||
|
@ -41,6 +41,12 @@ const addCustomTip = (amount) => {
|
|||||||
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
|
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const zapUndosThresholdReached = (me, amount) => {
|
||||||
|
if (!me) return false
|
||||||
|
const enabled = me.privates.zapUndos !== null
|
||||||
|
return enabled ? amount >= me.privates.zapUndos : false
|
||||||
|
}
|
||||||
|
|
||||||
export default function ItemAct ({ onClose, itemId, down, children }) {
|
export default function ItemAct ({ onClose, itemId, down, children }) {
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
@ -55,7 +61,7 @@ export default function ItemAct ({ onClose, itemId, down, children }) {
|
|||||||
|
|
||||||
const [act, actUpdate] = useAct()
|
const [act, actUpdate] = useAct()
|
||||||
|
|
||||||
const onSubmit = useCallback(async ({ amount, hash, hmac }, { update }) => {
|
const onSubmit = useCallback(async ({ amount, hash, hmac }, { update, keepOpen }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
const storageKey = `TIP-item:${itemId}`
|
const storageKey = `TIP-item:${itemId}`
|
||||||
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
|
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
|
||||||
@ -73,9 +79,9 @@ export default function ItemAct ({ onClose, itemId, down, children }) {
|
|||||||
})
|
})
|
||||||
// only strike when zap undos not enabled
|
// only strike when zap undos not enabled
|
||||||
// due to optimistic UX on zap undos
|
// due to optimistic UX on zap undos
|
||||||
if (!me || !me.privates.zapUndos) await strike()
|
if (!zapUndosThresholdReached(me, Number(amount))) await strike()
|
||||||
addCustomTip(Number(amount))
|
addCustomTip(Number(amount))
|
||||||
onClose()
|
if (!keepOpen) onClose(Number(amount))
|
||||||
}, [me, act, down, itemId, strike])
|
}, [me, act, down, itemId, strike])
|
||||||
|
|
||||||
const onSubmitWithUndos = withToastFlow(toaster)(
|
const onSubmitWithUndos = withToastFlow(toaster)(
|
||||||
@ -115,6 +121,7 @@ export default function ItemAct ({ onClose, itemId, down, children }) {
|
|||||||
return {
|
return {
|
||||||
skipToastFlow,
|
skipToastFlow,
|
||||||
flowId,
|
flowId,
|
||||||
|
tag: itemId,
|
||||||
type: 'zap',
|
type: 'zap',
|
||||||
pendingMessage: `${down ? 'down' : ''}zapped ${sats} sats`,
|
pendingMessage: `${down ? 'down' : ''}zapped ${sats} sats`,
|
||||||
onPending: async () => {
|
onPending: async () => {
|
||||||
@ -122,12 +129,12 @@ export default function ItemAct ({ onClose, itemId, down, children }) {
|
|||||||
return onSubmit(values, { flowId, ...args, update: null })
|
return onSubmit(values, { flowId, ...args, update: null })
|
||||||
}
|
}
|
||||||
await strike()
|
await strike()
|
||||||
onClose()
|
onClose(sats)
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
undoUpdate = update()
|
undoUpdate = update()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (canceled) return resolve()
|
if (canceled) return resolve()
|
||||||
onSubmit(values, { flowId, ...args, update: null })
|
onSubmit(values, { flowId, ...args, update: null, keepOpen: true })
|
||||||
.then(resolve)
|
.then(resolve)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
undoUpdate()
|
undoUpdate()
|
||||||
@ -155,7 +162,12 @@ export default function ItemAct ({ onClose, itemId, down, children }) {
|
|||||||
}}
|
}}
|
||||||
schema={amountSchema}
|
schema={amountSchema}
|
||||||
invoiceable
|
invoiceable
|
||||||
onSubmit={me?.privates?.zapUndos ? onSubmitWithUndos : onSubmit}
|
onSubmit={(values, ...args) => {
|
||||||
|
if (zapUndosThresholdReached(me, values.amount)) {
|
||||||
|
return onSubmitWithUndos(values, ...args)
|
||||||
|
}
|
||||||
|
return onSubmit(values, ...args)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
label='amount'
|
label='amount'
|
||||||
@ -375,16 +387,17 @@ export function useZap () {
|
|||||||
|
|
||||||
// add current sats to next tip since idempotent zaps use desired total zap not difference
|
// add current sats to next tip since idempotent zaps use desired total zap not difference
|
||||||
const sats = meSats + nextTip(meSats, { ...me?.privates })
|
const sats = meSats + nextTip(meSats, { ...me?.privates })
|
||||||
|
const amount = sats - meSats
|
||||||
|
|
||||||
const variables = { id: item.id, sats, act: 'TIP', amount: sats - meSats }
|
const variables = { id: item.id, sats, act: 'TIP', amount }
|
||||||
const insufficientFunds = me?.privates.sats < (sats - meSats)
|
const insufficientFunds = me?.privates.sats < amount
|
||||||
const optimisticResponse = { act: { path: item.path, ...variables } }
|
const optimisticResponse = { act: { path: item.path, ...variables } }
|
||||||
const flowId = (+new Date()).toString(16)
|
const flowId = (+new Date()).toString(16)
|
||||||
const zapArgs = { variables, optimisticResponse: insufficientFunds ? null : optimisticResponse, update, flowId }
|
const zapArgs = { variables, optimisticResponse: insufficientFunds ? null : optimisticResponse, update, flowId }
|
||||||
try {
|
try {
|
||||||
if (insufficientFunds) throw new Error('insufficient funds')
|
if (insufficientFunds) throw new Error('insufficient funds')
|
||||||
strike()
|
strike()
|
||||||
if (me?.privates?.zapUndos) {
|
if (zapUndosThresholdReached(me, amount)) {
|
||||||
await zapWithUndos(zapArgs)
|
await zapWithUndos(zapArgs)
|
||||||
} else {
|
} else {
|
||||||
await zap(zapArgs)
|
await zap(zapArgs)
|
||||||
|
@ -67,6 +67,10 @@ export const ToastProvider = ({ children }) => {
|
|||||||
}))
|
}))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const endFlow = useCallback((flowId) => {
|
||||||
|
setToasts(toasts => toasts.filter(toast => toast.flowId !== flowId))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const toaster = useMemo(() => ({
|
const toaster = useMemo(() => ({
|
||||||
success: (body, options) => {
|
success: (body, options) => {
|
||||||
const toast = {
|
const toast = {
|
||||||
@ -99,8 +103,9 @@ export const ToastProvider = ({ children }) => {
|
|||||||
...options
|
...options
|
||||||
}
|
}
|
||||||
return dispatchToast(toast)
|
return dispatchToast(toast)
|
||||||
}
|
},
|
||||||
}), [dispatchToast, removeToast])
|
endFlow
|
||||||
|
}), [dispatchToast, removeToast, endFlow])
|
||||||
|
|
||||||
// Only clear toasts with no cancel function on page navigation
|
// Only clear toasts with no cancel function on page navigation
|
||||||
// since navigation should not interfere with being able to cancel an action.
|
// since navigation should not interfere with being able to cancel an action.
|
||||||
@ -213,9 +218,6 @@ export const withToastFlow = (toaster) => flowFn => {
|
|||||||
|
|
||||||
if (skipToastFlow) return onPending()
|
if (skipToastFlow) return onPending()
|
||||||
|
|
||||||
// XXX HACK this ends the flow by using flow toast which immediately closes itself
|
|
||||||
const endFlow = () => toaster.warning('', { ...toastProps, delay: 0, autohide: true, flowId })
|
|
||||||
|
|
||||||
toaster.warning(pendingMessage || `${t} pending`, {
|
toaster.warning(pendingMessage || `${t} pending`, {
|
||||||
progressBar: !!timeout,
|
progressBar: !!timeout,
|
||||||
delay: timeout || TOAST_DEFAULT_DELAY_MS,
|
delay: timeout || TOAST_DEFAULT_DELAY_MS,
|
||||||
@ -247,7 +249,7 @@ export const withToastFlow = (toaster) => flowFn => {
|
|||||||
const ret = await onPending()
|
const ret = await onPending()
|
||||||
if (!canceled) {
|
if (!canceled) {
|
||||||
if (hideSuccess) {
|
if (hideSuccess) {
|
||||||
endFlow()
|
toaster.endFlow(flowId)
|
||||||
} else {
|
} else {
|
||||||
toaster.success(`${t} successful`, { ...toastProps, flowId })
|
toaster.success(`${t} successful`, { ...toastProps, flowId })
|
||||||
}
|
}
|
||||||
@ -259,7 +261,7 @@ export const withToastFlow = (toaster) => flowFn => {
|
|||||||
if (canceled) return
|
if (canceled) return
|
||||||
const reason = err?.message?.toString().toLowerCase() || 'unknown reason'
|
const reason = err?.message?.toString().toLowerCase() || 'unknown reason'
|
||||||
if (hideError) {
|
if (hideError) {
|
||||||
endFlow()
|
toaster.endFlow(flowId)
|
||||||
} else {
|
} else {
|
||||||
toaster.danger(`${t} failed: ${reason}`, { ...toastProps, flowId })
|
toaster.danger(`${t} failed: ${reason}`, { ...toastProps, flowId })
|
||||||
}
|
}
|
||||||
|
@ -24,10 +24,17 @@ async function discussionToEvent (item) {
|
|||||||
async function linkToEvent (item) {
|
async function linkToEvent (item) {
|
||||||
const createdAt = Math.floor(Date.now() / 1000)
|
const createdAt = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
let contentField
|
||||||
|
if (item.text) {
|
||||||
|
contentField = `${item.title}\n${item.url}\n\n${item.text}`
|
||||||
|
} else {
|
||||||
|
contentField = `${item.title}\n${item.url}`
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
created_at: createdAt,
|
created_at: createdAt,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
content: `${item.title} \n ${item.url}`,
|
content: contentField,
|
||||||
tags: []
|
tags: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ services:
|
|||||||
build: ./docker/db
|
build: ./docker/db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "PGPASSWORD=${POSTGRES_PASSWORD} psql -U ${POSTGRES_USER} ${POSTGRES_DB} -c 'SELECT 1 FROM users LIMIT 1'"]
|
test: ["CMD-SHELL", "PGPASSWORD=${POSTGRES_PASSWORD} pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB} -h 127.0.0.1 && psql -U ${POSTGRES_USER} ${POSTGRES_DB} -c 'SELECT 1 FROM users LIMIT 1'"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 10
|
retries: 10
|
||||||
@ -265,9 +265,9 @@ services:
|
|||||||
- '--externalip=sn_lnd'
|
- '--externalip=sn_lnd'
|
||||||
- '--tlsextradomain=sn_lnd'
|
- '--tlsextradomain=sn_lnd'
|
||||||
- '--tlsextradomain=host.docker.internal'
|
- '--tlsextradomain=host.docker.internal'
|
||||||
- '--listen=0.0.0.0:${LND_P2P_PORT}'
|
- '--listen=0.0.0.0:9735'
|
||||||
- '--rpclisten=0.0.0.0:${LND_GRPC_PORT}'
|
- '--rpclisten=0.0.0.0:10009'
|
||||||
- '--restlisten=0.0.0.0:${LND_REST_PORT}'
|
- '--restlisten=0.0.0.0:8080'
|
||||||
- '--bitcoin.active'
|
- '--bitcoin.active'
|
||||||
- '--bitcoin.regtest'
|
- '--bitcoin.regtest'
|
||||||
- '--bitcoin.node=bitcoind'
|
- '--bitcoin.node=bitcoind'
|
||||||
@ -282,10 +282,10 @@ services:
|
|||||||
- '--bitcoin.defaultchanconfs=1'
|
- '--bitcoin.defaultchanconfs=1'
|
||||||
- '--maxpendingchannels=10'
|
- '--maxpendingchannels=10'
|
||||||
expose:
|
expose:
|
||||||
- "${LND_P2P_PORT}"
|
- "9735"
|
||||||
ports:
|
ports:
|
||||||
- "${LND_REST_PORT}:${LND_REST_PORT}"
|
- "${LND_REST_PORT}:8080"
|
||||||
- "${LND_GRPC_PORT}:${LND_GRPC_PORT}"
|
- "${LND_GRPC_PORT}:10009"
|
||||||
volumes:
|
volumes:
|
||||||
- sn_lnd:/home/lnd/.lnd
|
- sn_lnd:/home/lnd/.lnd
|
||||||
labels:
|
labels:
|
||||||
@ -329,9 +329,9 @@ services:
|
|||||||
- '--externalip=stacker_lnd'
|
- '--externalip=stacker_lnd'
|
||||||
- '--tlsextradomain=stacker_lnd'
|
- '--tlsextradomain=stacker_lnd'
|
||||||
- '--tlsextradomain=host.docker.internal'
|
- '--tlsextradomain=host.docker.internal'
|
||||||
- '--listen=0.0.0.0:${LND_P2P_PORT}'
|
- '--listen=0.0.0.0:9735'
|
||||||
- '--rpclisten=0.0.0.0:${LND_GRPC_PORT}'
|
- '--rpclisten=0.0.0.0:10009'
|
||||||
- '--restlisten=0.0.0.0:${LND_REST_PORT}'
|
- '--restlisten=0.0.0.0:8080'
|
||||||
- '--bitcoin.active'
|
- '--bitcoin.active'
|
||||||
- '--bitcoin.regtest'
|
- '--bitcoin.regtest'
|
||||||
- '--bitcoin.node=bitcoind'
|
- '--bitcoin.node=bitcoind'
|
||||||
@ -346,10 +346,10 @@ services:
|
|||||||
- '--bitcoin.defaultchanconfs=1'
|
- '--bitcoin.defaultchanconfs=1'
|
||||||
- '--maxpendingchannels=10'
|
- '--maxpendingchannels=10'
|
||||||
expose:
|
expose:
|
||||||
- "${LND_P2P_PORT}"
|
- "9735"
|
||||||
ports:
|
ports:
|
||||||
- "${STACKER_LND_REST_PORT}:${LND_REST_PORT}"
|
- "${STACKER_LND_REST_PORT}:8080"
|
||||||
- "${STACKER_LND_GRPC_PORT}:${LND_GRPC_PORT}"
|
- "${STACKER_LND_GRPC_PORT}:10009"
|
||||||
volumes:
|
volumes:
|
||||||
- stacker_lnd:/home/lnd/.lnd
|
- stacker_lnd:/home/lnd/.lnd
|
||||||
labels:
|
labels:
|
||||||
|
15
lib/lnurl.js
15
lib/lnurl.js
@ -33,10 +33,19 @@ export async function lnAddrOptions (addr) {
|
|||||||
// support HTTP and HTTPS during development
|
// support HTTP and HTTPS during development
|
||||||
protocol = process.env.PUBLIC_URL.split('://')[0]
|
protocol = process.env.PUBLIC_URL.split('://')[0]
|
||||||
}
|
}
|
||||||
const req = await fetch(`${protocol}://${domain}/.well-known/lnurlp/${name}`)
|
const unexpectedErrorMessage = `An unexpected error occurred fetching the Lightning Address metadata for ${addr}. Check the address and try again.`
|
||||||
const res = await req.json()
|
let res
|
||||||
|
try {
|
||||||
|
const req = await fetch(`${protocol}://${domain}/.well-known/lnurlp/${name}`)
|
||||||
|
res = await req.json()
|
||||||
|
} catch (err) {
|
||||||
|
// If `fetch` fails, or if `req.json` fails, catch it here and surface a reasonable error
|
||||||
|
console.log('Error fetching lnurlp', err)
|
||||||
|
throw new Error(unexpectedErrorMessage)
|
||||||
|
}
|
||||||
if (res.status === 'ERROR') {
|
if (res.status === 'ERROR') {
|
||||||
throw new Error(res.reason)
|
// if the response doesn't adhere to spec by providing a `reason` entry, returns a default error message
|
||||||
|
throw new Error(res.reason ?? unexpectedErrorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { minSendable, maxSendable, ...leftOver } = res
|
const { minSendable, maxSendable, ...leftOver } = res
|
||||||
|
@ -1,143 +0,0 @@
|
|||||||
import { sendUserNotification } from '@/api/webPush'
|
|
||||||
import { ANON_USER_ID } from '@/lib/constants'
|
|
||||||
import { msatsToSats, numWithUnits } from '@/lib/format'
|
|
||||||
|
|
||||||
export const notifyUserSubscribers = async ({ models, item }) => {
|
|
||||||
try {
|
|
||||||
const isPost = !!item.title
|
|
||||||
const userSubs = await models.userSubscription.findMany({
|
|
||||||
where: {
|
|
||||||
followeeId: Number(item.userId),
|
|
||||||
[isPost ? 'postsSubscribedAt' : 'commentsSubscribedAt']: { not: null }
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
followee: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const subType = isPost ? 'POST' : 'COMMENT'
|
|
||||||
const tag = `FOLLOW-${item.userId}-${subType}`
|
|
||||||
await Promise.allSettled(userSubs.map(({ followerId, followee }) => sendUserNotification(followerId, {
|
|
||||||
title: `@${followee.name} ${isPost ? 'created a post' : 'replied to a post'}`,
|
|
||||||
body: isPost ? item.title : item.text,
|
|
||||||
item,
|
|
||||||
data: { followeeName: followee.name, 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 territorySubs = await models.subSubscription.findMany({
|
|
||||||
where: {
|
|
||||||
subName
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const author = await models.user.findUnique({ where: { id: item.userId } })
|
|
||||||
|
|
||||||
const tag = `TERRITORY_POST-${subName}`
|
|
||||||
await Promise.allSettled(
|
|
||||||
territorySubs
|
|
||||||
// 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, me }) => {
|
|
||||||
try {
|
|
||||||
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } })
|
|
||||||
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, id }) => {
|
|
||||||
try {
|
|
||||||
const updatedItem = await models.item.findUnique({ where: { id: Number(id) } })
|
|
||||||
const forwards = await models.itemForward.findMany({ where: { itemId: Number(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(updatedItem.msats) * mappedForwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100)
|
|
||||||
forwardedUsers = mappedForwards.map(fwd => `@${fwd.user.name}`).join(', ')
|
|
||||||
}
|
|
||||||
let notificationTitle
|
|
||||||
if (updatedItem.title) {
|
|
||||||
if (forwards.length > 0) {
|
|
||||||
notificationTitle = `your post forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}`
|
|
||||||
} else {
|
|
||||||
notificationTitle = `your post stacked ${numWithUnits(msatsToSats(updatedItem.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(updatedItem.msats))}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await sendUserNotification(updatedItem.userId, {
|
|
||||||
title: notificationTitle,
|
|
||||||
body: updatedItem.title ? updatedItem.title : updatedItem.text,
|
|
||||||
item: updatedItem,
|
|
||||||
tag: `TIP-${updatedItem.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(msatsToSats(updatedItem.msats) * forward.pct / 100)}`,
|
|
||||||
body: updatedItem.title ?? updatedItem.text,
|
|
||||||
item: updatedItem,
|
|
||||||
tag: `FORWARDEDTIP-${updatedItem.id}`
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -13,7 +13,7 @@ export function nextBilling (relativeTo, billingType) {
|
|||||||
|
|
||||||
export function purchasedType (sub) {
|
export function purchasedType (sub) {
|
||||||
if (!sub?.billPaidUntil) return 'ONCE'
|
if (!sub?.billPaidUntil) return 'ONCE'
|
||||||
return diffDays(new Date(sub.billedLastAt), new Date(sub.billPaidUntil)) >= 365 ? 'YEARLY' : 'MONTHLY'
|
return diffDays(new Date(sub.billedLastAt), new Date(sub.billPaidUntil)) >= 364 ? 'YEARLY' : 'MONTHLY'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function proratedBillingCost (sub, newBillingType) {
|
export function proratedBillingCost (sub, newBillingType) {
|
||||||
|
@ -530,7 +530,8 @@ export const settingsSchema = object({
|
|||||||
hideWalletBalance: boolean(),
|
hideWalletBalance: boolean(),
|
||||||
diagnostics: boolean(),
|
diagnostics: boolean(),
|
||||||
noReferralLinks: boolean(),
|
noReferralLinks: boolean(),
|
||||||
hideIsContributor: boolean()
|
hideIsContributor: boolean(),
|
||||||
|
zapUndos: intValidator.nullable().min(0, 'must be greater or equal to 0')
|
||||||
})
|
})
|
||||||
|
|
||||||
const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again'
|
const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again'
|
||||||
|
@ -85,6 +85,9 @@ const sendNotification = (subscription, payload) => {
|
|||||||
|
|
||||||
async function sendUserNotification (userId, notification) {
|
async function sendUserNotification (userId, notification) {
|
||||||
try {
|
try {
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('user id is required')
|
||||||
|
}
|
||||||
notification.data ??= {}
|
notification.data ??= {}
|
||||||
if (notification.item) {
|
if (notification.item) {
|
||||||
notification.data.url ??= await createItemUrl(notification.item)
|
notification.data.url ??= await createItemUrl(notification.item)
|
||||||
|
321
package-lock.json
generated
321
package-lock.json
generated
@ -2353,6 +2353,51 @@
|
|||||||
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.18.20",
|
"version": "0.18.20",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
|
||||||
@ -2368,6 +2413,276 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.18.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
|
||||||
|
"integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
|
||||||
@ -9105,9 +9420,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-tsconfig": {
|
"node_modules/get-tsconfig": {
|
||||||
"version": "4.7.2",
|
"version": "4.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.3.tgz",
|
||||||
"integrity": "sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==",
|
"integrity": "sha512-ZvkrzoUA0PQZM6fy6+/Hce561s+faD1rsNwhnO5FelNjyy7EMGJ3Rz1AQ8GYDWjhRs/7dBLOEJvhK8MiEJOAFg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resolve-pkg-maps": "^1.0.0"
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
},
|
},
|
||||||
|
@ -11,9 +11,10 @@ import { WHENS } from '@/lib/constants'
|
|||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import { whenToFrom } from '@/lib/time'
|
import { whenToFrom } from '@/lib/time'
|
||||||
|
import { WhenComposedChartSkeleton } from '@/components/charts-skeletons'
|
||||||
|
|
||||||
const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), {
|
const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), {
|
||||||
loading: () => <div>Loading...</div>
|
loading: () => <WhenComposedChartSkeleton />
|
||||||
})
|
})
|
||||||
|
|
||||||
const REFERRALS = gql`
|
const REFERRALS = gql`
|
||||||
|
@ -9,9 +9,10 @@ import { fixedDecimal } from '@/lib/format'
|
|||||||
import Trophy from '@/svgs/trophy-fill.svg'
|
import Trophy from '@/svgs/trophy-fill.svg'
|
||||||
import { ListItem } from '@/components/items'
|
import { ListItem } from '@/components/items'
|
||||||
import { dayMonthYear } from '@/lib/time'
|
import { dayMonthYear } from '@/lib/time'
|
||||||
|
import { GrowthPieChartSkeleton } from '@/components/charts-skeletons'
|
||||||
|
|
||||||
const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), {
|
const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), {
|
||||||
loading: () => <div>Loading...</div>
|
loading: () => <GrowthPieChartSkeleton />
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({
|
export const getServerSideProps = getGetServerSideProps({
|
||||||
|
@ -19,9 +19,10 @@ import { ListUsers } from '@/components/user-list'
|
|||||||
import { Col, Row } from 'react-bootstrap'
|
import { Col, Row } from 'react-bootstrap'
|
||||||
import { proportions } from '@/lib/madness'
|
import { proportions } from '@/lib/madness'
|
||||||
import { useData } from '@/components/use-data'
|
import { useData } from '@/components/use-data'
|
||||||
|
import { GrowthPieChartSkeleton } from '@/components/charts-skeletons'
|
||||||
|
|
||||||
const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), {
|
const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), {
|
||||||
loading: () => <div>Loading...</div>
|
loading: () => <GrowthPieChartSkeleton />
|
||||||
})
|
})
|
||||||
|
|
||||||
const REWARDS_FULL = gql`
|
const REWARDS_FULL = gql`
|
||||||
|
@ -28,6 +28,7 @@ import { useMe } from '@/components/me'
|
|||||||
import { INVOICE_RETENTION_DAYS } from '@/lib/constants'
|
import { INVOICE_RETENTION_DAYS } from '@/lib/constants'
|
||||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
||||||
import DeleteIcon from '@/svgs/delete-bin-line.svg'
|
import DeleteIcon from '@/svgs/delete-bin-line.svg'
|
||||||
|
import { useField } from 'formik'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
||||||
|
|
||||||
@ -65,7 +66,8 @@ export default function Settings ({ ssrData }) {
|
|||||||
initial={{
|
initial={{
|
||||||
tipDefault: settings?.tipDefault || 21,
|
tipDefault: settings?.tipDefault || 21,
|
||||||
turboTipping: settings?.turboTipping,
|
turboTipping: settings?.turboTipping,
|
||||||
zapUndos: settings?.zapUndos,
|
zapUndos: settings?.zapUndos || settings?.tipDefault ? 100 * settings.tipDefault : 2100,
|
||||||
|
zapUndosEnabled: settings?.zapUndos !== null,
|
||||||
fiatCurrency: settings?.fiatCurrency || 'USD',
|
fiatCurrency: settings?.fiatCurrency || 'USD',
|
||||||
withdrawMaxFeeDefault: settings?.withdrawMaxFeeDefault,
|
withdrawMaxFeeDefault: settings?.withdrawMaxFeeDefault,
|
||||||
noteItemSats: settings?.noteItemSats,
|
noteItemSats: settings?.noteItemSats,
|
||||||
@ -98,7 +100,7 @@ export default function Settings ({ ssrData }) {
|
|||||||
noReferralLinks: settings?.noReferralLinks
|
noReferralLinks: settings?.noReferralLinks
|
||||||
}}
|
}}
|
||||||
schema={settingsSchema}
|
schema={settingsSchema}
|
||||||
onSubmit={async ({ tipDefault, withdrawMaxFeeDefault, nostrPubkey, nostrRelays, ...values }) => {
|
onSubmit={async ({ tipDefault, withdrawMaxFeeDefault, zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, ...values }) => {
|
||||||
if (nostrPubkey.length === 0) {
|
if (nostrPubkey.length === 0) {
|
||||||
nostrPubkey = null
|
nostrPubkey = null
|
||||||
} else {
|
} else {
|
||||||
@ -116,6 +118,7 @@ export default function Settings ({ ssrData }) {
|
|||||||
settings: {
|
settings: {
|
||||||
tipDefault: Number(tipDefault),
|
tipDefault: Number(tipDefault),
|
||||||
withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault),
|
withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault),
|
||||||
|
zapUndos: zapUndosEnabled ? Number(zapUndos) : null,
|
||||||
nostrPubkey,
|
nostrPubkey,
|
||||||
nostrRelays: nostrRelaysFiltered,
|
nostrRelays: nostrRelaysFiltered,
|
||||||
...values
|
...values
|
||||||
@ -171,25 +174,7 @@ export default function Settings ({ ssrData }) {
|
|||||||
}
|
}
|
||||||
groupClassName='mb-0'
|
groupClassName='mb-0'
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<ZapUndosField />
|
||||||
name='zapUndos'
|
|
||||||
label={
|
|
||||||
<div className='d-flex align-items-center'>zap undos
|
|
||||||
<Info>
|
|
||||||
<ul className='fw-bold'>
|
|
||||||
<li>An undo button is shown after every zap</li>
|
|
||||||
<li>The button is shown for 5 seconds</li>
|
|
||||||
<li>
|
|
||||||
The button is only shown for zaps from the custodial wallet
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Use a budget or manual approval with attached wallets
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</Info>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -920,3 +905,36 @@ I estimate that I will call the GraphQL API this many times (rough estimate is f
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ZapUndosField = () => {
|
||||||
|
const [checkboxField] = useField({ name: 'zapUndosEnabled' })
|
||||||
|
return (
|
||||||
|
<div className='d-flex flex-row align-items-center'>
|
||||||
|
<Input
|
||||||
|
name='zapUndos'
|
||||||
|
disabled={!checkboxField.value}
|
||||||
|
label={
|
||||||
|
<Checkbox
|
||||||
|
name='zapUndosEnabled'
|
||||||
|
groupClassName='mb-0'
|
||||||
|
label={
|
||||||
|
<div className='d-flex align-items-center'>
|
||||||
|
zap undos
|
||||||
|
<Info>
|
||||||
|
<ul className='fw-bold'>
|
||||||
|
<li>An undo button is shown after every zap that exceeds or is equal to the threshold</li>
|
||||||
|
<li>The button is shown for 5 seconds</li>
|
||||||
|
<li>The button is only shown for zaps from the custodial wallet</li>
|
||||||
|
<li>Use a budget or manual approval with attached wallets</li>
|
||||||
|
</ul>
|
||||||
|
</Info>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
|
hint={<small className='text-muted'>threshold at which undo button is shown</small>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -7,15 +7,16 @@ import { UsageHeader } from '@/components/usage-header'
|
|||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import PageLoading from '@/components/page-loading'
|
import PageLoading from '@/components/page-loading'
|
||||||
|
import { WhenAreaChartSkeleton, WhenComposedChartSkeleton, WhenLineChartSkeleton } from '@/components/charts-skeletons'
|
||||||
|
|
||||||
const WhenAreaChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenAreaChart), {
|
const WhenAreaChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenAreaChart), {
|
||||||
loading: () => <div>Loading...</div>
|
loading: () => <WhenAreaChartSkeleton />
|
||||||
})
|
})
|
||||||
const WhenLineChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenLineChart), {
|
const WhenLineChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenLineChart), {
|
||||||
loading: () => <div>Loading...</div>
|
loading: () => <WhenLineChartSkeleton />
|
||||||
})
|
})
|
||||||
const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), {
|
const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), {
|
||||||
loading: () => <div>Loading...</div>
|
loading: () => <WhenComposedChartSkeleton />
|
||||||
})
|
})
|
||||||
|
|
||||||
const GROWTH_QUERY = gql`
|
const GROWTH_QUERY = gql`
|
||||||
|
83
prisma/migrations/20240323222903_replies/migration.sql
Normal file
83
prisma/migrations/20240323222903_replies/migration.sql
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Reply" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"ancestorId" INTEGER NOT NULL,
|
||||||
|
"ancestorUserId" INTEGER NOT NULL,
|
||||||
|
"itemId" INTEGER NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"level" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Reply_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reply_ancestorId_idx" ON "Reply"("ancestorId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reply_ancestorUserId_idx" ON "Reply"("ancestorUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reply_level_idx" ON "Reply"("level");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reply_created_at_idx" ON "Reply"("created_at");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Reply" ADD CONSTRAINT "Reply_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Reply" ADD CONSTRAINT "Reply_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Reply" ADD CONSTRAINT "Reply_ancestorUserId_fkey" FOREIGN KEY ("ancestorUserId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Reply" ADD CONSTRAINT "Reply_ancestorId_fkey" FOREIGN KEY ("ancestorId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION ncomments_after_comment() RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
user_trust DOUBLE PRECISION;
|
||||||
|
BEGIN
|
||||||
|
-- grab user's trust who is commenting
|
||||||
|
SELECT trust INTO user_trust FROM users WHERE id = NEW."userId";
|
||||||
|
|
||||||
|
UPDATE "Item"
|
||||||
|
SET "lastCommentAt" = now_utc(), "ncomments" = "ncomments" + 1
|
||||||
|
WHERE id <> NEW.id and path @> NEW.path;
|
||||||
|
|
||||||
|
-- we only want to add the user's trust to weightedComments if they aren't
|
||||||
|
-- already the author of a descendant comment
|
||||||
|
UPDATE "Item"
|
||||||
|
SET "weightedComments" = "weightedComments" + user_trust
|
||||||
|
FROM (
|
||||||
|
-- for every ancestor of the new comment, return the ones that don't have
|
||||||
|
-- the same author in their descendants
|
||||||
|
SELECT p.id
|
||||||
|
FROM "Item" p
|
||||||
|
-- all decendants of p that aren't the new comment
|
||||||
|
JOIN "Item" c ON c.path <@ p.path AND c.id <> NEW.id
|
||||||
|
-- p is an ancestor of this comment, it isn't itself, and it doesn't have the same author
|
||||||
|
WHERE p.path @> NEW.path AND p.id <> NEW.id AND p."userId" <> NEW."userId"
|
||||||
|
GROUP BY p.id
|
||||||
|
-- only return p if it doesn't have any descendants with the same author as the comment
|
||||||
|
HAVING bool_and(c."userId" <> NEW."userId")
|
||||||
|
) fresh
|
||||||
|
WHERE "Item".id = fresh.id;
|
||||||
|
|
||||||
|
-- insert the comment into the reply table for every ancestor
|
||||||
|
INSERT INTO "Reply" (created_at, updated_at, "ancestorId", "ancestorUserId", "itemId", "userId", level)
|
||||||
|
SELECT NEW.created_at, NEW.updated_at, p.id, p."userId", NEW.id, NEW."userId", nlevel(NEW.path) - nlevel(p.path)
|
||||||
|
FROM "Item" p
|
||||||
|
WHERE p.path @> NEW.path AND p.id <> NEW.id AND p."userId" <> NEW."userId";
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- insert the comment into the reply table for every ancestor retroactively
|
||||||
|
INSERT INTO "Reply" (created_at, updated_at, "ancestorId", "ancestorUserId", "itemId", "userId", level)
|
||||||
|
SELECT c.created_at, c.created_at, p.id, p."userId", c.id, c."userId", nlevel(c.path) - nlevel(p.path)
|
||||||
|
FROM "Item" p
|
||||||
|
JOIN "Item" c ON c.path <@ p.path AND c.id <> p.id AND p."userId" <> c."userId";
|
45
prisma/migrations/20240324164838_last_zap_at/migration.sql
Normal file
45
prisma/migrations/20240324164838_last_zap_at/migration.sql
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Item" ADD COLUMN "lastZapAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Item_lastZapAt_idx" ON "Item"("lastZapAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reply_itemId_idx" ON "Reply"("itemId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reply_userId_idx" ON "Reply"("userId");
|
||||||
|
|
||||||
|
|
||||||
|
-- when an item is zapped, update the lastZapAt field
|
||||||
|
CREATE OR REPLACE FUNCTION sats_after_tip(item_id INTEGER, user_id INTEGER, tip_msats BIGINT) RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
item "Item";
|
||||||
|
BEGIN
|
||||||
|
SELECT * FROM "Item" WHERE id = item_id INTO item;
|
||||||
|
IF user_id <> 27 AND item."userId" = user_id THEN
|
||||||
|
RETURN 0;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE "Item"
|
||||||
|
SET "msats" = "msats" + tip_msats,
|
||||||
|
"lastZapAt" = now()
|
||||||
|
WHERE id = item.id;
|
||||||
|
|
||||||
|
UPDATE "Item"
|
||||||
|
SET "commentMsats" = "commentMsats" + tip_msats
|
||||||
|
WHERE id <> item.id and path @> item.path;
|
||||||
|
|
||||||
|
RETURN 1;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- retrofit the lastZapAt field for all existing items
|
||||||
|
UPDATE "Item" SET "lastZapAt" = "Zap".at
|
||||||
|
FROM (
|
||||||
|
SELECT "ItemAct"."itemId", MAX("ItemAct"."created_at") AS at
|
||||||
|
FROM "ItemAct"
|
||||||
|
WHERE "ItemAct".act = 'TIP'
|
||||||
|
GROUP BY "ItemAct"."itemId"
|
||||||
|
) AS "Zap"
|
||||||
|
WHERE "Item"."id" = "Zap"."itemId";
|
@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "zapUndosTmp" INTEGER;
|
||||||
|
UPDATE "users" SET "zapUndosTmp" = CASE WHEN "zapUndos" = false THEN NULL ELSE 0::INTEGER END;
|
||||||
|
ALTER TABLE "users" DROP COLUMN "zapUndos";
|
||||||
|
ALTER TABLE "users" RENAME COLUMN "zapUndosTmp" TO "zapUndos";
|
@ -26,7 +26,7 @@ model User {
|
|||||||
checkedNotesAt DateTime?
|
checkedNotesAt DateTime?
|
||||||
foundNotesAt DateTime?
|
foundNotesAt DateTime?
|
||||||
pubkey String? @unique(map: "users.pubkey_unique")
|
pubkey String? @unique(map: "users.pubkey_unique")
|
||||||
apiKey String? @db.Char(32) @unique(map: "users.apikey_unique")
|
apiKey String? @unique(map: "users.apikey_unique") @db.Char(32)
|
||||||
apiKeyEnabled Boolean @default(false)
|
apiKeyEnabled Boolean @default(false)
|
||||||
tipDefault Int @default(100)
|
tipDefault Int @default(100)
|
||||||
bioId Int?
|
bioId Int?
|
||||||
@ -56,7 +56,7 @@ model User {
|
|||||||
autoDropBolt11s Boolean @default(false)
|
autoDropBolt11s Boolean @default(false)
|
||||||
hideFromTopUsers Boolean @default(false)
|
hideFromTopUsers Boolean @default(false)
|
||||||
turboTipping Boolean @default(false)
|
turboTipping Boolean @default(false)
|
||||||
zapUndos Boolean @default(false)
|
zapUndos Int?
|
||||||
imgproxyOnly Boolean @default(false)
|
imgproxyOnly Boolean @default(false)
|
||||||
hideWalletBalance Boolean @default(false)
|
hideWalletBalance Boolean @default(false)
|
||||||
referrerId Int?
|
referrerId Int?
|
||||||
@ -117,8 +117,10 @@ model User {
|
|||||||
SubAct SubAct[]
|
SubAct SubAct[]
|
||||||
MuteSub MuteSub[]
|
MuteSub MuteSub[]
|
||||||
Wallet Wallet[]
|
Wallet Wallet[]
|
||||||
TerritoryTransfers TerritoryTransfer[] @relation("TerritoryTransfer_oldUser")
|
TerritoryTransfers TerritoryTransfer[] @relation("TerritoryTransfer_oldUser")
|
||||||
TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser")
|
TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser")
|
||||||
|
AncestorReplies Reply[] @relation("AncestorReplyUser")
|
||||||
|
Replies Reply[]
|
||||||
|
|
||||||
@@index([photoId])
|
@@index([photoId])
|
||||||
@@index([createdAt], map: "users.created_at_index")
|
@@index([createdAt], map: "users.created_at_index")
|
||||||
@ -353,6 +355,7 @@ model Item {
|
|||||||
paidImgLink Boolean @default(false)
|
paidImgLink Boolean @default(false)
|
||||||
commentMsats BigInt @default(0)
|
commentMsats BigInt @default(0)
|
||||||
lastCommentAt DateTime?
|
lastCommentAt DateTime?
|
||||||
|
lastZapAt DateTime?
|
||||||
ncomments Int @default(0)
|
ncomments Int @default(0)
|
||||||
msats BigInt @default(0)
|
msats BigInt @default(0)
|
||||||
weightedDownVotes Float @default(0)
|
weightedDownVotes Float @default(0)
|
||||||
@ -387,8 +390,11 @@ model Item {
|
|||||||
uploadId Int?
|
uploadId Int?
|
||||||
outlawed Boolean @default(false)
|
outlawed Boolean @default(false)
|
||||||
pollExpiresAt DateTime?
|
pollExpiresAt DateTime?
|
||||||
|
Ancestors Reply[] @relation("AncestorReplyItem")
|
||||||
|
Replies Reply[]
|
||||||
|
|
||||||
@@index([uploadId])
|
@@index([uploadId])
|
||||||
|
@@index([lastZapAt])
|
||||||
@@index([bio], map: "Item.bio_index")
|
@@index([bio], map: "Item.bio_index")
|
||||||
@@index([createdAt], map: "Item.created_at_index")
|
@@index([createdAt], map: "Item.created_at_index")
|
||||||
@@index([freebie], map: "Item.freebie_index")
|
@@index([freebie], map: "Item.freebie_index")
|
||||||
@ -406,6 +412,30 @@ model Item {
|
|||||||
@@index([weightedVotes], map: "Item.weightedVotes_index")
|
@@index([weightedVotes], map: "Item.weightedVotes_index")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// this is a denomalized table that is used to make reply notifications
|
||||||
|
// more efficient ... it is populated by a trigger when replies are created
|
||||||
|
model Reply {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
|
ancestorId Int
|
||||||
|
ancestorUserId Int
|
||||||
|
itemId Int
|
||||||
|
userId Int
|
||||||
|
level Int
|
||||||
|
User User @relation(fields: [userId], references: [id])
|
||||||
|
Item Item @relation(fields: [itemId], references: [id])
|
||||||
|
AncestorUser User @relation("AncestorReplyUser", fields: [ancestorUserId], references: [id])
|
||||||
|
AncestorItem Item @relation("AncestorReplyItem", fields: [ancestorId], references: [id])
|
||||||
|
|
||||||
|
@@index([ancestorId])
|
||||||
|
@@index([ancestorUserId])
|
||||||
|
@@index([itemId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([level])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: make all Item's forward 100% of sats to the OP by default
|
// TODO: make all Item's forward 100% of sats to the OP by default
|
||||||
// so that forwards aren't a special case everywhere
|
// so that forwards aren't a special case everywhere
|
||||||
model ItemForward {
|
model ItemForward {
|
||||||
@ -488,13 +518,13 @@ model Sub {
|
|||||||
moderatedCount Int @default(0)
|
moderatedCount Int @default(0)
|
||||||
nsfw Boolean @default(false)
|
nsfw Boolean @default(false)
|
||||||
|
|
||||||
parent Sub? @relation("ParentChildren", fields: [parentName], references: [name])
|
parent Sub? @relation("ParentChildren", fields: [parentName], references: [name])
|
||||||
children Sub[] @relation("ParentChildren")
|
children Sub[] @relation("ParentChildren")
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
Item Item[]
|
Item Item[]
|
||||||
SubAct SubAct[]
|
SubAct SubAct[]
|
||||||
MuteSub MuteSub[]
|
MuteSub MuteSub[]
|
||||||
SubSubscription SubSubscription[]
|
SubSubscription SubSubscription[]
|
||||||
TerritoryTransfer TerritoryTransfer[]
|
TerritoryTransfer TerritoryTransfer[]
|
||||||
|
|
||||||
@@index([parentName])
|
@@index([parentName])
|
||||||
@ -779,14 +809,14 @@ model Log {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TerritoryTransfer {
|
model TerritoryTransfer {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
oldUserId Int
|
oldUserId Int
|
||||||
newUserId Int
|
newUserId Int
|
||||||
subName String @db.Citext
|
subName String @db.Citext
|
||||||
oldUser User @relation("TerritoryTransfer_oldUser", fields: [oldUserId], references: [id], onDelete: Cascade)
|
oldUser User @relation("TerritoryTransfer_oldUser", fields: [oldUserId], references: [id], onDelete: Cascade)
|
||||||
newUser User @relation("TerritoryTransfer_newUser", fields: [newUserId], references: [id], onDelete: Cascade)
|
newUser User @relation("TerritoryTransfer_newUser", fields: [newUserId], references: [id], onDelete: Cascade)
|
||||||
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade)
|
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([createdAt, newUserId], map: "TerritoryTransfer.newUserId_index")
|
@@index([createdAt, newUserId], map: "TerritoryTransfer.newUserId_index")
|
||||||
@@index([createdAt, oldUserId], map: "TerritoryTransfer.oldUserId_index")
|
@@index([createdAt, oldUserId], map: "TerritoryTransfer.oldUserId_index")
|
||||||
|
@ -25,6 +25,34 @@ const ITEMS = gql`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const TOP_COWBOYS = gql`
|
||||||
|
query TopCowboys($cursor: String) {
|
||||||
|
topCowboys(cursor: $cursor) {
|
||||||
|
users {
|
||||||
|
name
|
||||||
|
optional {
|
||||||
|
streak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const TOP_USERS = gql`
|
||||||
|
query TopUsers($cursor: String, $when: String, $from: String, $to: String, $by: String, ) {
|
||||||
|
topUsers(cursor: $cursor, when: $when, from: $from, to: $to, by: $by) {
|
||||||
|
users {
|
||||||
|
name
|
||||||
|
optional {
|
||||||
|
stacked(when: $when, from: $from, to: $to)
|
||||||
|
spent(when: $when, from: $from, to: $to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
const client = new ApolloClient({
|
const client = new ApolloClient({
|
||||||
link: new HttpLink({ uri: 'https://stacker.news/api/graphql' }),
|
link: new HttpLink({ uri: 'https://stacker.news/api/graphql' }),
|
||||||
cache: new InMemoryCache()
|
cache: new InMemoryCache()
|
||||||
@ -83,6 +111,34 @@ async function bountyWinner (q) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getTopUsers ({ by, cowboys = false, includeHidden = false, count = 5, when = 'week' } = {}) {
|
||||||
|
const accum = []
|
||||||
|
let cursor = ''
|
||||||
|
try {
|
||||||
|
while (accum.length < count) {
|
||||||
|
let variables = {
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
if (!cowboys) {
|
||||||
|
variables = {
|
||||||
|
...variables,
|
||||||
|
by,
|
||||||
|
when
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await client.query({
|
||||||
|
query: cowboys ? TOP_COWBOYS : TOP_USERS,
|
||||||
|
variables
|
||||||
|
})
|
||||||
|
cursor = result.data[cowboys ? 'topCowboys' : 'topUsers'].cursor
|
||||||
|
accum.push(...result.data[cowboys ? 'topCowboys' : 'topUsers'].users.filter(user => includeHidden ? true : !!user))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
}
|
||||||
|
return accum.slice(0, count)
|
||||||
|
}
|
||||||
|
|
||||||
async function main () {
|
async function main () {
|
||||||
const { quote } = await import('../lib/md.js')
|
const { quote } = await import('../lib/md.js')
|
||||||
|
|
||||||
@ -104,6 +160,10 @@ async function main () {
|
|||||||
const topMeme = await bountyWinner('meme monday')
|
const topMeme = await bountyWinner('meme monday')
|
||||||
const topFact = await bountyWinner('fun fact')
|
const topFact = await bountyWinner('fun fact')
|
||||||
|
|
||||||
|
const topCowboys = await getTopUsers({ cowboys: true })
|
||||||
|
const topStackers = await getTopUsers({ by: 'stacking' })
|
||||||
|
const topSpenders = await getTopUsers({ by: 'spent' })
|
||||||
|
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`Happy Sat-urday Stackers,
|
`Happy Sat-urday Stackers,
|
||||||
|
|
||||||
@ -143,6 +203,27 @@ ${topFact && quote(topFact?.winner.text)}
|
|||||||
|
|
||||||
------
|
------
|
||||||
|
|
||||||
|
##### Top Stackers
|
||||||
|
${topStackers.map((user, i) =>
|
||||||
|
`${i + 1}. [@${user.name}](https://stacker.news/${user.name}): ${abbrNum(user.optional.stacked)} sats stacked`
|
||||||
|
).join('\n')}
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
##### Top Spenders
|
||||||
|
${topSpenders.map((user, i) =>
|
||||||
|
`${i + 1}. [@${user.name}](https://stacker.news/${user.name}): ${abbrNum(user.optional.spent)} sats spent`
|
||||||
|
).join('\n')}
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
##### Top Cowboys
|
||||||
|
${topCowboys.map((user, i) =>
|
||||||
|
`${i + 1}. [@${user.name}](https://stacker.news/${user.name}): ${user.optional.streak} days`
|
||||||
|
).join('\n')}
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
##### Promoted jobs
|
##### Promoted jobs
|
||||||
${jobs.data.items.items.filter(i => i.maxBid > 0 && i.status === 'ACTIVE').slice(0, 5).map((item, i) =>
|
${jobs.data.items.items.filter(i => i.maxBid > 0 && i.status === 'ACTIVE').slice(0, 5).map((item, i) =>
|
||||||
`${i + 1}. [${item.title.trim()} \\ ${item.company} \\ ${item.location}${item.remote ? ' or Remote' : ''}](https://stacker.news/items/${item.id})\n`).join('')}
|
`${i + 1}. [${item.title.trim()} \\ ${item.company} \\ ${item.location}${item.remote ? ' or Remote' : ''}](https://stacker.news/items/${item.id})\n`).join('')}
|
||||||
|
13
sndev
13
sndev
@ -26,14 +26,6 @@ docker__exec() {
|
|||||||
command docker exec -i "$@"
|
command docker exec -i "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
docker__sn_lnd() {
|
|
||||||
docker__exec -u lnd sn_lnd lncli "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
docker__stacker_lnd() {
|
|
||||||
docker__exec -u lnd stacker_lnd lncli "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
docker__sn_lnd() {
|
docker__sn_lnd() {
|
||||||
t=$1
|
t=$1
|
||||||
if [ "$t" = "-t" ]; then
|
if [ "$t" = "-t" ]; then
|
||||||
@ -318,6 +310,11 @@ USAGE
|
|||||||
|
|
||||||
sndev__login() {
|
sndev__login() {
|
||||||
shift
|
shift
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "<nym> argument required"
|
||||||
|
sndev__help_login
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
# hardcode token for which is the hex digest of the sha256 of
|
# hardcode token for which is the hex digest of the sha256 of
|
||||||
# "SNDEV-TOKEN3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI"
|
# "SNDEV-TOKEN3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI"
|
||||||
# next-auth concats the token with the secret from env and then sha256's it
|
# next-auth concats the token with the secret from env and then sha256's it
|
||||||
|
Loading…
x
Reference in New Issue
Block a user