Compare commits
33 Commits
0ca9596310
...
566a5f9675
Author | SHA1 | Date | |
---|---|---|---|
|
566a5f9675 | ||
|
1cbf5ab871 | ||
|
a895a91277 | ||
|
630af6bc40 | ||
|
d9932e0a27 | ||
|
3ce9d7339c | ||
|
d688e9fce8 | ||
|
5c1129c98f | ||
|
8905868a62 | ||
|
c40ef5a1c1 | ||
|
4520c91179 | ||
|
90ccbc58b1 | ||
|
8d548ce152 | ||
|
2137dacf14 | ||
|
7daf688ea3 | ||
|
5261c83f4d | ||
|
466620ff05 | ||
|
44d3748d5f | ||
|
511b0eea40 | ||
|
b37a12bf71 | ||
|
c74107269d | ||
|
29c31e3d4a | ||
|
0750a7b197 | ||
|
95950cdff8 | ||
|
529b5d1fef | ||
|
a04647d304 | ||
|
34175babb2 | ||
|
3fc1291600 | ||
|
146b60278c | ||
|
47debbcb06 | ||
|
077727dced | ||
|
d53bc09773 | ||
|
6a02ea8c5c |
@ -194,6 +194,12 @@ All functions have the following signature: `function(args: Object, context: Obj
|
||||
- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment)
|
||||
- `lnd`: the current lnd client
|
||||
|
||||
## Recording Cowboy Credits
|
||||
|
||||
To avoid adding sats and credits together everywhere to show an aggregate sat value, in most cases we denormalize a `sats` field that carries the "sats value", the combined sats + credits of something, and a `credits` field that carries only the earned `credits`. For example, the `Item` table has an `msats` field that carries the sum of the `mcredits` and `msats` earned and a `mcredits` field that carries the value of the `mcredits` earned. So, the sats value an item earned is `item.msats` BUT the real sats earned is `item.msats - item.mcredits`.
|
||||
|
||||
The ONLY exception to this are for the `users` table where we store a stacker's rewards sats and credits balances separately.
|
||||
|
||||
## `IMPORTANT: transaction isolation`
|
||||
|
||||
We use a `read committed` isolation level for actions. This means paid actions need to be mindful of concurrency issues. Specifically, reading data from the database and then writing it back in `read committed` is a common source of consistency bugs (aka serialization anamolies).
|
||||
|
@ -5,6 +5,7 @@ export const anonable = false
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
|
||||
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
|
||||
]
|
||||
|
||||
@ -67,9 +68,9 @@ export async function onPaid ({ invoice, actId }, { tx }) {
|
||||
})
|
||||
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
|
||||
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil)
|
||||
VALUES ('expireBoost', jsonb_build_object('id', ${itemAct.itemId}::INTEGER), 21, true,
|
||||
now() + interval '30 days', interval '40 days')`
|
||||
now() + interval '30 days', now() + interval '40 days')`
|
||||
}
|
||||
|
||||
export async function onFail ({ invoice }, { tx }) {
|
||||
|
32
api/paidAction/buyCredits.js
Normal file
32
api/paidAction/buyCredits.js
Normal file
@ -0,0 +1,32 @@
|
||||
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
||||
import { satsToMsats } from '@/lib/format'
|
||||
|
||||
export const anonable = false
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
|
||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||
]
|
||||
|
||||
export async function getCost ({ credits }) {
|
||||
return satsToMsats(credits)
|
||||
}
|
||||
|
||||
export async function perform ({ credits }, { me, cost, tx }) {
|
||||
await tx.user.update({
|
||||
where: { id: me.id },
|
||||
data: {
|
||||
mcredits: {
|
||||
increment: cost
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
credits
|
||||
}
|
||||
}
|
||||
|
||||
export async function describe () {
|
||||
return 'SN: buy fee credits'
|
||||
}
|
@ -5,6 +5,7 @@ export const anonable = true
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
|
||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||
]
|
||||
|
||||
|
@ -5,6 +5,7 @@ export const anonable = false
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
|
||||
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
|
||||
]
|
||||
|
||||
|
@ -18,6 +18,7 @@ import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
|
||||
import * as DONATE from './donate'
|
||||
import * as BOOST from './boost'
|
||||
import * as RECEIVE from './receive'
|
||||
import * as BUY_CREDITS from './buyCredits'
|
||||
import * as INVITE_GIFT from './inviteGift'
|
||||
|
||||
export const paidActions = {
|
||||
@ -33,6 +34,7 @@ export const paidActions = {
|
||||
TERRITORY_UNARCHIVE,
|
||||
DONATE,
|
||||
RECEIVE,
|
||||
BUY_CREDITS,
|
||||
INVITE_GIFT
|
||||
}
|
||||
|
||||
@ -96,7 +98,8 @@ export default async function performPaidAction (actionType, args, incomingConte
|
||||
|
||||
// additional payment methods that logged in users can use
|
||||
if (me) {
|
||||
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) {
|
||||
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT ||
|
||||
paymentMethod === PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) {
|
||||
try {
|
||||
return await performNoInvoiceAction(actionType, args, contextWithPaymentMethod)
|
||||
} catch (e) {
|
||||
@ -141,6 +144,13 @@ async function performNoInvoiceAction (actionType, args, incomingContext) {
|
||||
const context = { ...incomingContext, tx }
|
||||
|
||||
if (paymentMethod === 'FEE_CREDIT') {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: me?.id ?? USER_ID.anon
|
||||
},
|
||||
data: { mcredits: { decrement: cost } }
|
||||
})
|
||||
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: me?.id ?? USER_ID.anon
|
||||
@ -461,11 +471,11 @@ async function createDbInvoice (actionType, args, context) {
|
||||
|
||||
// insert a job to check the invoice after it's set to expire
|
||||
await db.$executeRaw`
|
||||
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein, priority)
|
||||
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil, priority)
|
||||
VALUES ('checkInvoice',
|
||||
jsonb_build_object('hash', ${invoice.hash}::TEXT), 21, true,
|
||||
${expiresAt}::TIMESTAMP WITH TIME ZONE,
|
||||
${expiresAt}::TIMESTAMP WITH TIME ZONE - now() + interval '10m', 100)`
|
||||
${expiresAt}::TIMESTAMP WITH TIME ZONE + interval '10m', 100)`
|
||||
|
||||
// the HMAC is only returned during invoice creation
|
||||
// this makes sure that only the person who created this invoice
|
||||
|
@ -5,7 +5,8 @@ import { notifyInvite } from '@/lib/webPush'
|
||||
export const anonable = false
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS
|
||||
]
|
||||
|
||||
export async function getCost ({ id }, { models, me }) {
|
||||
@ -36,7 +37,7 @@ export async function perform ({ id, userId }, { me, cost, tx }) {
|
||||
}
|
||||
},
|
||||
data: {
|
||||
msats: {
|
||||
mcredits: {
|
||||
increment: cost
|
||||
},
|
||||
inviteId: id,
|
||||
|
@ -8,6 +8,7 @@ export const anonable = true
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
|
||||
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC,
|
||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||
]
|
||||
@ -29,7 +30,7 @@ export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio },
|
||||
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon,
|
||||
// cost must be greater than user's balance, and user has not disabled freebies
|
||||
const freebie = (parentId || bio) && cost <= baseCost && !!me &&
|
||||
cost > me?.msats && !me?.disableFreebies
|
||||
me?.msats < cost && !me?.disableFreebies && me?.mcredits < cost
|
||||
|
||||
return freebie ? BigInt(0) : BigInt(cost)
|
||||
}
|
||||
@ -216,9 +217,9 @@ export async function onPaid ({ invoice, id }, context) {
|
||||
|
||||
if (item.boost > 0) {
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
|
||||
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil)
|
||||
VALUES ('expireBoost', jsonb_build_object('id', ${item.id}::INTEGER), 21, true,
|
||||
now() + interval '30 days', interval '40 days')`
|
||||
now() + interval '30 days', now() + interval '40 days')`
|
||||
}
|
||||
|
||||
if (item.parentId) {
|
||||
|
@ -8,6 +8,7 @@ export const anonable = true
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
|
||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||
]
|
||||
|
||||
@ -137,15 +138,15 @@ export async function perform (args, context) {
|
||||
})
|
||||
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
|
||||
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil)
|
||||
VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true,
|
||||
now() + interval '5 seconds', interval '1 day')`
|
||||
now() + interval '5 seconds', now() + interval '1 day')`
|
||||
|
||||
if (newBoost > 0) {
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
|
||||
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil)
|
||||
VALUES ('expireBoost', jsonb_build_object('id', ${id}::INTEGER), 21, true,
|
||||
now() + interval '30 days', interval '40 days')`
|
||||
now() + interval '30 days', now() + interval '40 days')`
|
||||
}
|
||||
|
||||
await performBotBehavior(args, context)
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { BALANCE_LIMIT_MSATS, PAID_ACTION_TERMINAL_STATES, USER_ID, SN_ADMIN_IDS } from '@/lib/constants'
|
||||
import { msatsToSats, numWithUnits } from '@/lib/format'
|
||||
import { PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants'
|
||||
import { datePivot } from '@/lib/time'
|
||||
|
||||
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
|
||||
const MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES = 10
|
||||
const MAX_PENDING_DIRECT_INVOICES_PER_USER = 100
|
||||
const USER_IDS_BALANCE_NO_LIMIT = [...SN_ADMIN_IDS, USER_ID.anon, USER_ID.ad]
|
||||
|
||||
export async function assertBelowMaxPendingInvoices (context) {
|
||||
const { models, me } = context
|
||||
@ -56,47 +54,3 @@ export async function assertBelowMaxPendingDirectPayments (userId, context) {
|
||||
throw new Error('Receiver has too many direct payments')
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertBelowBalanceLimit (context) {
|
||||
const { me, tx } = context
|
||||
if (!me || USER_IDS_BALANCE_NO_LIMIT.includes(me.id)) return
|
||||
|
||||
// we need to prevent this invoice (and any other pending invoices and withdrawls)
|
||||
// from causing the user's balance to exceed the balance limit
|
||||
const pendingInvoices = await tx.invoice.aggregate({
|
||||
where: {
|
||||
userId: me.id,
|
||||
// p2p invoices are never in state PENDING
|
||||
actionState: 'PENDING',
|
||||
actionType: 'RECEIVE'
|
||||
},
|
||||
_sum: {
|
||||
msatsRequested: true
|
||||
}
|
||||
})
|
||||
|
||||
// Get pending withdrawals total
|
||||
const pendingWithdrawals = await tx.withdrawl.aggregate({
|
||||
where: {
|
||||
userId: me.id,
|
||||
status: null
|
||||
},
|
||||
_sum: {
|
||||
msatsPaying: true,
|
||||
msatsFeePaying: true
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate total pending amount
|
||||
const pendingMsats = (pendingInvoices._sum.msatsRequested ?? 0n) +
|
||||
((pendingWithdrawals._sum.msatsPaying ?? 0n) + (pendingWithdrawals._sum.msatsFeePaying ?? 0n))
|
||||
|
||||
// Check balance limit
|
||||
if (pendingMsats + me.msats > BALANCE_LIMIT_MSATS) {
|
||||
throw new Error(
|
||||
`pending invoices and withdrawals must not cause balance to exceed ${
|
||||
numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -60,23 +60,23 @@ export async function performBotBehavior ({ text, id }, { me, tx }) {
|
||||
const deleteAt = getDeleteAt(text)
|
||||
if (deleteAt) {
|
||||
await tx.$queryRaw`
|
||||
INSERT INTO pgboss.job (name, data, startafter, expirein)
|
||||
INSERT INTO pgboss.job (name, data, startafter, keepuntil)
|
||||
VALUES (
|
||||
'deleteItem',
|
||||
jsonb_build_object('id', ${id}::INTEGER),
|
||||
${deleteAt}::TIMESTAMP WITH TIME ZONE,
|
||||
${deleteAt}::TIMESTAMP WITH TIME ZONE - now() + interval '1 minute')`
|
||||
${deleteAt}::TIMESTAMP WITH TIME ZONE + interval '1 minute')`
|
||||
}
|
||||
|
||||
const remindAt = getRemindAt(text)
|
||||
if (remindAt) {
|
||||
await tx.$queryRaw`
|
||||
INSERT INTO pgboss.job (name, data, startafter, expirein)
|
||||
INSERT INTO pgboss.job (name, data, startafter, keepuntil)
|
||||
VALUES (
|
||||
'reminder',
|
||||
jsonb_build_object('itemId', ${id}::INTEGER, 'userId', ${userId}::INTEGER),
|
||||
${remindAt}::TIMESTAMP WITH TIME ZONE,
|
||||
${remindAt}::TIMESTAMP WITH TIME ZONE - now() + interval '1 minute')`
|
||||
${remindAt}::TIMESTAMP WITH TIME ZONE + interval '1 minute')`
|
||||
await tx.reminder.create({
|
||||
data: {
|
||||
userId,
|
||||
|
@ -5,6 +5,7 @@ export const anonable = false
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
|
||||
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
|
||||
]
|
||||
|
||||
|
@ -2,7 +2,6 @@ import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
||||
import { toPositiveBigInt, numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
|
||||
import { notifyDeposit } from '@/lib/webPush'
|
||||
import { getInvoiceableWallets } from '@/wallets/server'
|
||||
import { assertBelowBalanceLimit } from './lib/assert'
|
||||
|
||||
export const anonable = false
|
||||
|
||||
@ -19,13 +18,16 @@ export async function getCost ({ msats }) {
|
||||
export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) {
|
||||
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null
|
||||
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && !me?.directReceive) return null
|
||||
if ((cost + me.msats) <= satsToMsats(me.autoWithdrawThreshold)) return null
|
||||
|
||||
const wallets = await getInvoiceableWallets(me.id, { models })
|
||||
if (wallets.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (cost < satsToMsats(me.receiveCreditsBelowSats)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return me.id
|
||||
}
|
||||
|
||||
@ -39,7 +41,7 @@ export async function perform ({
|
||||
lud18Data,
|
||||
noteStr
|
||||
}, { me, tx }) {
|
||||
const invoice = await tx.invoice.update({
|
||||
return await tx.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
comment,
|
||||
@ -48,11 +50,6 @@ export async function perform ({
|
||||
},
|
||||
include: { invoiceForward: true }
|
||||
})
|
||||
|
||||
if (!invoice.invoiceForward) {
|
||||
// if the invoice is not p2p, assert that the user's balance limit is not exceeded
|
||||
await assertBelowBalanceLimit({ me, tx })
|
||||
}
|
||||
}
|
||||
|
||||
export async function describe ({ description }, { me, cost, paymentMethod, sybilFeePercent }) {
|
||||
@ -73,7 +70,7 @@ export async function onPaid ({ invoice }, { tx }) {
|
||||
await tx.user.update({
|
||||
where: { id: invoice.userId },
|
||||
data: {
|
||||
msats: {
|
||||
mcredits: {
|
||||
increment: invoice.msatsReceived
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ export const anonable = false
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
|
||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||
]
|
||||
|
||||
|
@ -6,6 +6,7 @@ export const anonable = false
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
|
||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||
]
|
||||
|
||||
|
@ -6,6 +6,7 @@ export const anonable = false
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
|
||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||
]
|
||||
|
||||
|
@ -7,6 +7,7 @@ export const anonable = false
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
|
||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||
]
|
||||
|
||||
|
@ -6,8 +6,9 @@ import { getInvoiceableWallets } from '@/wallets/server'
|
||||
export const anonable = true
|
||||
|
||||
export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.P2P,
|
||||
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
|
||||
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC,
|
||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||
]
|
||||
@ -16,16 +17,38 @@ export async function getCost ({ sats }) {
|
||||
return satsToMsats(sats)
|
||||
}
|
||||
|
||||
export async function getInvoiceablePeer ({ id }, { models }) {
|
||||
export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models, me, cost }) {
|
||||
// if the zap is dust, or if me doesn't have a send wallet but has enough sats/credits to pay for it
|
||||
// then we don't invoice the peer
|
||||
if (sats < me?.sendCreditsBelowSats ||
|
||||
(me && !hasSendWallet && (me.mcredits >= cost || me.msats >= cost))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const item = await models.item.findUnique({
|
||||
where: { id: parseInt(id) },
|
||||
include: { itemForwards: true }
|
||||
include: {
|
||||
itemForwards: true,
|
||||
user: true
|
||||
}
|
||||
})
|
||||
|
||||
// bios don't get sats
|
||||
if (item.bio) {
|
||||
return null
|
||||
}
|
||||
|
||||
const wallets = await getInvoiceableWallets(item.userId, { models })
|
||||
|
||||
// request peer invoice if they have an attached wallet and have not forwarded the item
|
||||
return wallets.length > 0 && item.itemForwards.length === 0 ? item.userId : null
|
||||
// and the receiver doesn't want to receive credits
|
||||
if (wallets.length > 0 &&
|
||||
item.itemForwards.length === 0 &&
|
||||
sats >= item.user.receiveCreditsBelowSats) {
|
||||
return item.userId
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getSybilFeePercent () {
|
||||
@ -90,32 +113,38 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
|
||||
const sats = msatsToSats(msats)
|
||||
const itemAct = acts.find(act => act.act === 'TIP')
|
||||
|
||||
// give user and all forwards the sats
|
||||
await tx.$executeRaw`
|
||||
WITH forwardees AS (
|
||||
SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS msats
|
||||
FROM "ItemForward"
|
||||
WHERE "itemId" = ${itemAct.itemId}::INTEGER
|
||||
), total_forwarded AS (
|
||||
SELECT COALESCE(SUM(msats), 0) as msats
|
||||
FROM forwardees
|
||||
), recipients AS (
|
||||
SELECT "userId", msats, msats AS "stackedMsats" FROM forwardees
|
||||
UNION
|
||||
SELECT ${itemAct.item.userId}::INTEGER as "userId",
|
||||
CASE WHEN ${!!invoice?.invoiceForward}::BOOLEAN
|
||||
THEN 0::BIGINT
|
||||
ELSE ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT
|
||||
END as msats,
|
||||
${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT as "stackedMsats"
|
||||
ORDER BY "userId" ASC -- order to prevent deadlocks
|
||||
)
|
||||
UPDATE users
|
||||
SET
|
||||
msats = users.msats + recipients.msats,
|
||||
"stackedMsats" = users."stackedMsats" + recipients."stackedMsats"
|
||||
FROM recipients
|
||||
WHERE users.id = recipients."userId"`
|
||||
if (invoice?.invoiceForward) {
|
||||
// only the op got sats and we need to add it to their stackedMsats
|
||||
// because the sats were p2p
|
||||
await tx.user.update({
|
||||
where: { id: itemAct.item.userId },
|
||||
data: { stackedMsats: { increment: itemAct.msats } }
|
||||
})
|
||||
} else {
|
||||
// splits only use mcredits
|
||||
await tx.$executeRaw`
|
||||
WITH forwardees AS (
|
||||
SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS mcredits
|
||||
FROM "ItemForward"
|
||||
WHERE "itemId" = ${itemAct.itemId}::INTEGER
|
||||
), total_forwarded AS (
|
||||
SELECT COALESCE(SUM(mcredits), 0) as mcredits
|
||||
FROM forwardees
|
||||
), recipients AS (
|
||||
SELECT "userId", mcredits FROM forwardees
|
||||
UNION
|
||||
SELECT ${itemAct.item.userId}::INTEGER as "userId",
|
||||
${itemAct.msats}::BIGINT - (SELECT mcredits FROM total_forwarded)::BIGINT as mcredits
|
||||
ORDER BY "userId" ASC -- order to prevent deadlocks
|
||||
)
|
||||
UPDATE users
|
||||
SET
|
||||
mcredits = users.mcredits + recipients.mcredits,
|
||||
"stackedMsats" = users."stackedMsats" + recipients.mcredits,
|
||||
"stackedMcredits" = users."stackedMcredits" + recipients.mcredits
|
||||
FROM recipients
|
||||
WHERE users.id = recipients."userId"`
|
||||
}
|
||||
|
||||
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
|
||||
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
|
||||
@ -135,6 +164,7 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
|
||||
"weightedVotes" = "weightedVotes" + (zapper.trust * zap.log_sats),
|
||||
upvotes = upvotes + zap.first_vote,
|
||||
msats = "Item".msats + ${msats}::BIGINT,
|
||||
mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT,
|
||||
"lastZapAt" = now()
|
||||
FROM zap, zapper
|
||||
WHERE "Item".id = ${itemAct.itemId}::INTEGER
|
||||
@ -165,7 +195,8 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
|
||||
SELECT * FROM "Item" WHERE id = ${itemAct.itemId}::INTEGER
|
||||
)
|
||||
UPDATE "Item"
|
||||
SET "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT
|
||||
SET "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT,
|
||||
"commentMcredits" = "Item"."commentMcredits" + ${invoice?.invoiceForward ? 0n : msats}::BIGINT
|
||||
FROM zapped
|
||||
WHERE "Item".path @> zapped.path AND "Item".id <> zapped.id`
|
||||
}
|
||||
@ -175,7 +206,17 @@ export async function nonCriticalSideEffects ({ invoice, actIds }, { models }) {
|
||||
where: invoice ? { invoiceId: invoice.id } : { id: { in: actIds } },
|
||||
include: { item: true }
|
||||
})
|
||||
notifyZapped({ models, item: itemAct.item }).catch(console.error)
|
||||
// avoid duplicate notifications with the same zap amount
|
||||
// by checking if there are any other pending acts on the item
|
||||
const pendingActs = await models.itemAct.count({
|
||||
where: {
|
||||
itemId: itemAct.itemId,
|
||||
createdAt: {
|
||||
gt: itemAct.createdAt
|
||||
}
|
||||
}
|
||||
})
|
||||
if (pendingActs === 0) notifyZapped({ models, item: itemAct.item }).catch(console.error)
|
||||
}
|
||||
|
||||
export async function onFail ({ invoice }, { tx }) {
|
||||
|
@ -83,7 +83,7 @@ export default {
|
||||
},
|
||||
poor: async (invite, args, { me, models }) => {
|
||||
const user = await models.user.findUnique({ where: { id: invite.userId } })
|
||||
return msatsToSats(user.msats) < invite.gift
|
||||
return msatsToSats(user.msats) < invite.gift && msatsToSats(user.mcredits) < invite.gift
|
||||
},
|
||||
description: (invite, args, { me }) => {
|
||||
return invite.userId === me?.id ? invite.description : undefined
|
||||
|
@ -150,6 +150,7 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
|
||||
return await models.$queryRawUnsafe(`
|
||||
SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user,
|
||||
COALESCE("ItemAct"."meMsats", 0) as "meMsats", COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats",
|
||||
COALESCE("ItemAct"."meMcredits", 0) as "meMcredits", COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits",
|
||||
COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark",
|
||||
"ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward",
|
||||
to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL)
|
||||
@ -167,10 +168,14 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
|
||||
LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id}
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT "itemId",
|
||||
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND (act = 'FEE' OR act = 'TIP')) AS "meMsats",
|
||||
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM 'PENDING' AND (act = 'FEE' OR act = 'TIP') AND "Item"."userId" <> ${me.id}) AS "mePendingMsats",
|
||||
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND "InvoiceForward".id IS NOT NULL AND (act = 'FEE' OR act = 'TIP')) AS "meMsats",
|
||||
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND "InvoiceForward".id IS NULL AND (act = 'FEE' OR act = 'TIP')) AS "meMcredits",
|
||||
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM 'PENDING' AND "InvoiceForward".id IS NOT NULL AND (act = 'FEE' OR act = 'TIP')) AS "mePendingMsats",
|
||||
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM 'PENDING' AND "InvoiceForward".id IS NULL AND (act = 'FEE' OR act = 'TIP')) AS "mePendingMcredits",
|
||||
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND act = 'DONT_LIKE_THIS') AS "meDontLikeMsats"
|
||||
FROM "ItemAct"
|
||||
LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId"
|
||||
LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id"
|
||||
WHERE "ItemAct"."userId" = ${me.id}
|
||||
AND "ItemAct"."itemId" = "Item".id
|
||||
GROUP BY "ItemAct"."itemId"
|
||||
@ -940,7 +945,7 @@ export default {
|
||||
|
||||
return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd })
|
||||
},
|
||||
act: async (parent, { id, sats, act = 'TIP' }, { me, models, lnd, headers }) => {
|
||||
act: async (parent, { id, sats, act = 'TIP', hasSendWallet }, { me, models, lnd, headers }) => {
|
||||
assertApiKeyNotPermitted({ me })
|
||||
await validateSchema(actSchema, { sats, act })
|
||||
await assertGofacYourself({ models, headers })
|
||||
@ -974,7 +979,7 @@ export default {
|
||||
}
|
||||
|
||||
if (act === 'TIP') {
|
||||
return await performPaidAction('ZAP', { id, sats }, { me, models, lnd })
|
||||
return await performPaidAction('ZAP', { id, sats, hasSendWallet }, { me, models, lnd })
|
||||
} else if (act === 'DONT_LIKE_THIS') {
|
||||
return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd })
|
||||
} else if (act === 'BOOST') {
|
||||
@ -1048,12 +1053,24 @@ export default {
|
||||
}
|
||||
},
|
||||
Item: {
|
||||
sats: async (item, args, { models }) => {
|
||||
return msatsToSats(BigInt(item.msats) + BigInt(item.mePendingMsats || 0))
|
||||
sats: async (item, args, { models, me }) => {
|
||||
if (me?.id === item.userId) {
|
||||
return msatsToSats(BigInt(item.msats))
|
||||
}
|
||||
return msatsToSats(BigInt(item.msats) + BigInt(item.mePendingMsats || 0) + BigInt(item.mePendingMcredits || 0))
|
||||
},
|
||||
credits: async (item, args, { models, me }) => {
|
||||
if (me?.id === item.userId) {
|
||||
return msatsToSats(BigInt(item.mcredits))
|
||||
}
|
||||
return msatsToSats(BigInt(item.mcredits) + BigInt(item.mePendingMcredits || 0))
|
||||
},
|
||||
commentSats: async (item, args, { models }) => {
|
||||
return msatsToSats(item.commentMsats)
|
||||
},
|
||||
commentCredits: async (item, args, { models }) => {
|
||||
return msatsToSats(item.commentMcredits)
|
||||
},
|
||||
isJob: async (item, args, { models }) => {
|
||||
return item.subName === 'jobs'
|
||||
},
|
||||
@ -1170,8 +1187,8 @@ export default {
|
||||
},
|
||||
meSats: async (item, args, { me, models }) => {
|
||||
if (!me) return 0
|
||||
if (typeof item.meMsats !== 'undefined') {
|
||||
return msatsToSats(item.meMsats)
|
||||
if (typeof item.meMsats !== 'undefined' && typeof item.meMcredits !== 'undefined') {
|
||||
return msatsToSats(BigInt(item.meMsats) + BigInt(item.meMcredits))
|
||||
}
|
||||
|
||||
const { _sum: { msats } } = await models.itemAct.aggregate({
|
||||
@ -1197,6 +1214,38 @@ export default {
|
||||
|
||||
return (msats && msatsToSats(msats)) || 0
|
||||
},
|
||||
meCredits: async (item, args, { me, models }) => {
|
||||
if (!me) return 0
|
||||
if (typeof item.meMcredits !== 'undefined') {
|
||||
return msatsToSats(item.meMcredits)
|
||||
}
|
||||
|
||||
const { _sum: { msats } } = await models.itemAct.aggregate({
|
||||
_sum: {
|
||||
msats: true
|
||||
},
|
||||
where: {
|
||||
itemId: Number(item.id),
|
||||
userId: me.id,
|
||||
invoiceActionState: {
|
||||
not: 'FAILED'
|
||||
},
|
||||
invoice: {
|
||||
invoiceForward: { is: null }
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
act: 'TIP'
|
||||
},
|
||||
{
|
||||
act: 'FEE'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
return (msats && msatsToSats(msats)) || 0
|
||||
},
|
||||
meDontLikeSats: async (item, args, { me, models }) => {
|
||||
if (!me) return 0
|
||||
if (typeof item.meDontLikeMsats !== 'undefined') {
|
||||
@ -1385,7 +1434,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
|
||||
// but forever if an admin is editing an "admin item", it's their bio or a job
|
||||
const myBio = user.bioId === old.id
|
||||
const timer = Date.now() < datePivot(new Date(old.invoicePaidAt ?? old.createdAt), { seconds: ITEM_EDIT_SECONDS })
|
||||
const canEdit = (timer && ownerEdit) || adminEdit || myBio || isJob(item)
|
||||
const canEdit = (timer && ownerEdit) || adminEdit || myBio || isJob(old)
|
||||
if (!canEdit) {
|
||||
throw new GqlInputError('item can no longer be edited')
|
||||
}
|
||||
|
@ -247,7 +247,7 @@ export default {
|
||||
WHERE "Withdrawl"."userId" = $1
|
||||
AND "Withdrawl".status = 'CONFIRMED'
|
||||
AND "Withdrawl".created_at < $2
|
||||
AND ("InvoiceForward"."id" IS NULL OR "Invoice"."actionType" = 'ZAP')
|
||||
AND "InvoiceForward"."id" IS NULL
|
||||
GROUP BY "Withdrawl".id
|
||||
ORDER BY "sortTime" DESC
|
||||
LIMIT ${LIMIT})`
|
||||
|
@ -21,6 +21,8 @@ function paidActionType (actionType) {
|
||||
return 'PollVotePaidAction'
|
||||
case 'RECEIVE':
|
||||
return 'ReceivePaidAction'
|
||||
case 'BUY_CREDITS':
|
||||
return 'BuyCreditsPaidAction'
|
||||
default:
|
||||
throw new Error('Unknown action type')
|
||||
}
|
||||
|
@ -441,26 +441,6 @@ export default {
|
||||
}
|
||||
|
||||
if (user.noteWithdrawals) {
|
||||
const p2pZap = await models.invoice.findFirst({
|
||||
where: {
|
||||
confirmedAt: {
|
||||
gt: lastChecked
|
||||
},
|
||||
invoiceForward: {
|
||||
withdrawl: {
|
||||
userId: me.id,
|
||||
status: 'CONFIRMED',
|
||||
updatedAt: {
|
||||
gt: lastChecked
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
if (p2pZap) {
|
||||
foundNotes()
|
||||
return true
|
||||
}
|
||||
const wdrwl = await models.withdrawl.findFirst({
|
||||
where: {
|
||||
userId: me.id,
|
||||
@ -1024,7 +1004,13 @@ export default {
|
||||
if (!me || me.id !== user.id) {
|
||||
return 0
|
||||
}
|
||||
return msatsToSats(user.msats)
|
||||
return msatsToSats(user.msats + user.mcredits)
|
||||
},
|
||||
credits: async (user, args, { models, me }) => {
|
||||
if (!me || me.id !== user.id) {
|
||||
return 0
|
||||
}
|
||||
return msatsToSats(user.mcredits)
|
||||
},
|
||||
authMethods,
|
||||
hasInvites: async (user, args, { models }) => {
|
||||
@ -1106,7 +1092,7 @@ export default {
|
||||
|
||||
if (!when || when === 'forever') {
|
||||
// forever
|
||||
return (user.stackedMsats && msatsToSats(user.stackedMsats)) || 0
|
||||
return ((user.stackedMsats && msatsToSats(user.stackedMsats)) || 0)
|
||||
}
|
||||
|
||||
const range = whenRange(when, from, to)
|
||||
|
@ -134,11 +134,13 @@ export async function getWithdrawl (parent, { id }, { me, models, lnd }) {
|
||||
}
|
||||
|
||||
export function createHmac (hash) {
|
||||
if (!hash) throw new GqlInputError('hash required to create hmac')
|
||||
const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex')
|
||||
return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
|
||||
}
|
||||
|
||||
export function verifyHmac (hash, hmac) {
|
||||
if (!hash || !hmac) throw new GqlInputError('hash or hmac missing')
|
||||
const hmac2 = createHmac(hash)
|
||||
if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
|
||||
throw new GqlAuthorizationError('bad hmac')
|
||||
@ -487,10 +489,17 @@ const resolvers = {
|
||||
},
|
||||
createWithdrawl: createWithdrawal,
|
||||
sendToLnAddr,
|
||||
cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => {
|
||||
verifyHmac(hash, hmac)
|
||||
cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => {
|
||||
// stackers can cancel their own invoices without hmac
|
||||
if (me && !hmac) {
|
||||
const inv = await models.invoice.findUnique({ where: { hash } })
|
||||
if (!inv) throw new GqlInputError('invoice not found')
|
||||
if (inv.userId !== me.id) throw new GqlInputError('not ur invoice')
|
||||
} else {
|
||||
verifyHmac(hash, hmac)
|
||||
}
|
||||
await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
|
||||
return await models.invoice.findFirst({ where: { hash } })
|
||||
return await models.invoice.update({ where: { hash }, data: { userCancel: !!userCancel } })
|
||||
},
|
||||
dropBolt11: async (parent, { hash }, { me, models, lnd }) => {
|
||||
if (!me) {
|
||||
@ -574,6 +583,9 @@ const resolvers = {
|
||||
await models.walletLog.deleteMany({ where: { userId: me.id, wallet } })
|
||||
|
||||
return true
|
||||
},
|
||||
buyCredits: async (parent, { credits }, { me, models, lnd }) => {
|
||||
return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd })
|
||||
}
|
||||
},
|
||||
|
||||
@ -635,6 +647,9 @@ const resolvers = {
|
||||
}))?.withdrawl?.msatsPaid
|
||||
return msats ? msatsToSats(msats) : null
|
||||
},
|
||||
invoiceForward: async (invoice, args, { models }) => {
|
||||
return !!invoice.invoiceForward || !!(await models.invoiceForward.findUnique({ where: { invoiceId: Number(invoice.id) } }))
|
||||
},
|
||||
nostr: async (invoice, args, { models }) => {
|
||||
try {
|
||||
return JSON.parse(invoice.desc)
|
||||
|
@ -60,7 +60,7 @@ export default gql`
|
||||
hash: String, hmac: String): ItemPaidAction!
|
||||
updateNoteId(id: ID!, noteId: String!): Item!
|
||||
upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction!
|
||||
act(id: ID!, sats: Int, act: String, idempotent: Boolean): ItemActPaidAction!
|
||||
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
|
||||
pollVote(id: ID!): PollVotePaidAction!
|
||||
toggleOutlaw(id: ID!): Item!
|
||||
}
|
||||
@ -127,10 +127,13 @@ export default gql`
|
||||
bountyPaidTo: [Int]
|
||||
noteId: String
|
||||
sats: Int!
|
||||
credits: Int!
|
||||
commentSats: Int!
|
||||
commentCredits: Int!
|
||||
lastCommentAt: Date
|
||||
upvotes: Int!
|
||||
meSats: Int!
|
||||
meCredits: Int!
|
||||
meDontLikeSats: Int!
|
||||
meBookmark: Boolean!
|
||||
meSubscription: Boolean!
|
||||
|
@ -11,6 +11,7 @@ extend type Mutation {
|
||||
}
|
||||
|
||||
enum PaymentMethod {
|
||||
REWARD_SATS
|
||||
FEE_CREDIT
|
||||
ZERO_COST
|
||||
OPTIMISTIC
|
||||
@ -52,4 +53,9 @@ type DonatePaidAction implements PaidAction {
|
||||
paymentMethod: PaymentMethod!
|
||||
}
|
||||
|
||||
type BuyCreditsPaidAction implements PaidAction {
|
||||
result: BuyCreditsResult
|
||||
invoice: Invoice
|
||||
paymentMethod: PaymentMethod!
|
||||
}
|
||||
`
|
||||
|
@ -114,6 +114,8 @@ export default gql`
|
||||
withdrawMaxFeeDefault: Int!
|
||||
proxyReceive: Boolean
|
||||
directReceive: Boolean
|
||||
receiveCreditsBelowSats: Int!
|
||||
sendCreditsBelowSats: Int!
|
||||
}
|
||||
|
||||
type AuthMethods {
|
||||
@ -130,6 +132,7 @@ export default gql`
|
||||
extremely sensitive
|
||||
"""
|
||||
sats: Int!
|
||||
credits: Int!
|
||||
authMethods: AuthMethods!
|
||||
lnAddr: String
|
||||
|
||||
@ -194,6 +197,8 @@ export default gql`
|
||||
walletsUpdatedAt: Date
|
||||
proxyReceive: Boolean
|
||||
directReceive: Boolean
|
||||
receiveCreditsBelowSats: Int!
|
||||
sendCreditsBelowSats: Int!
|
||||
}
|
||||
|
||||
type UserOptional {
|
||||
|
@ -78,11 +78,16 @@ const typeDefs = `
|
||||
createInvoice(amount: Int!): InvoiceOrDirect!
|
||||
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
|
||||
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
|
||||
cancelInvoice(hash: String!, hmac: String!): Invoice!
|
||||
cancelInvoice(hash: String!, hmac: String, userCancel: Boolean): Invoice!
|
||||
dropBolt11(hash: String!): Boolean
|
||||
removeWallet(id: ID!): Boolean
|
||||
deleteWalletLogs(wallet: String): Boolean
|
||||
setWalletPriority(id: ID!, priority: Int!): Boolean
|
||||
buyCredits(credits: Int!): BuyCreditsPaidAction!
|
||||
}
|
||||
|
||||
type BuyCreditsResult {
|
||||
credits: Int!
|
||||
}
|
||||
|
||||
interface InvoiceOrDirect {
|
||||
@ -126,6 +131,7 @@ const typeDefs = `
|
||||
actionState: String
|
||||
actionType: String
|
||||
actionError: String
|
||||
invoiceForward: Boolean
|
||||
item: Item
|
||||
itemAct: ItemAct
|
||||
forwardedSats: Int
|
||||
|
@ -160,3 +160,9 @@ Darth-Coin,issue,#1649,#1421,medium,,,,25k,darthcoin@stacker.news,2024-12-07
|
||||
Soxasora,pr,#1685,,medium,,,,250k,soxasora@blink.sv,2024-12-07
|
||||
aegroto,pr,#1606,#1242,medium,,,,250k,aegroto@blink.sv,2024-12-07
|
||||
sfr0xyz,issue,#1696,#1196,good-first-issue,,,,2k,sefiro@getalby.com,2024-12-10
|
||||
Soxasora,pr,#1794,#756,hard,urgent,,,3m,bolt11,2024-01-09
|
||||
Soxasora,pr,#1794,#411,hard,high,sort of grouped with #1794,,1m,bolt11,2024-01-09
|
||||
SatsAllDay,issue,#1749,#411,hard,high,,,200k,weareallsatoshi@getalby.com,???
|
||||
Soxasora,pr,#1786,#363,easy,,,,100k,soxasora@blink.sv,???
|
||||
felipebueno,issue,#1786,#363,easy,,,,10k,felipebueno@getalby.com,???
|
||||
cyphercosmo,pr,#1745,#1648,good-first-issue,,,2,16k,cyphercosmo@getalby.com,???
|
||||
|
|
@ -75,7 +75,7 @@ export function BadgeTooltip ({ children, overlayText, placement }) {
|
||||
<OverlayTrigger
|
||||
placement={placement || 'bottom'}
|
||||
overlay={
|
||||
<Tooltip>
|
||||
<Tooltip style={{ position: 'fixed' }}>
|
||||
{overlayText}
|
||||
</Tooltip>
|
||||
}
|
||||
|
@ -5,8 +5,6 @@ import { useMe } from '@/components/me'
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
|
||||
import { useToast } from '@/components/toast'
|
||||
import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
|
||||
import { msatsToSats, numWithUnits } from '@/lib/format'
|
||||
import Link from 'next/link'
|
||||
|
||||
export function WelcomeBanner ({ Banner }) {
|
||||
@ -102,27 +100,6 @@ export function MadnessBanner ({ handleClose }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function WalletLimitBanner () {
|
||||
const { me } = useMe()
|
||||
|
||||
const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
|
||||
if (!me || !limitReached) return
|
||||
|
||||
return (
|
||||
<Alert className={styles.banner} key='info' variant='warning'>
|
||||
<Alert.Heading>
|
||||
Your wallet is over the current limit ({numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))})
|
||||
</Alert.Heading>
|
||||
<p className='mb-1'>
|
||||
Deposits to your wallet from <strong>outside</strong> of SN are blocked.
|
||||
</p>
|
||||
<p>
|
||||
Please spend or withdraw sats to restore full wallet functionality.
|
||||
</p>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export function WalletSecurityBanner ({ isActive }) {
|
||||
return (
|
||||
<Alert className={styles.banner} key='info' variant='warning'>
|
||||
|
@ -114,7 +114,7 @@ export function FeeButtonProvider ({ baseLineItems = DEFAULT_BASE_LINE_ITEMS, us
|
||||
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
|
||||
const total = Object.values(lines).sort(sortHelper).reduce((acc, { modifier }) => modifier(acc), 0)
|
||||
// freebies: there's only a base cost and we don't have enough sats
|
||||
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total && !me?.privates?.disableFreebies
|
||||
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total && me?.privates?.credits < total && !me?.privates?.disableFreebies
|
||||
return {
|
||||
lines,
|
||||
merge: mergeLineItems,
|
||||
|
27
components/info/cc.js
Normal file
27
components/info/cc.js
Normal file
@ -0,0 +1,27 @@
|
||||
import Link from 'next/link'
|
||||
import Info from '.'
|
||||
|
||||
export default function CCInfo (props) {
|
||||
return (
|
||||
<Info {...props}>
|
||||
<h6>Why am I getting cowboy credits?</h6>
|
||||
<ul className='line-height-md'>
|
||||
<li>to receive sats, you must attach an <Link href='/wallets'>external receiving wallet</Link></li>
|
||||
<li>zappers may have chosen to send you CCs instead of sats</li>
|
||||
<li>if the zaps are split on a post, recepients will receive CCs regardless of their configured receiving wallet</li>
|
||||
<li>there could be an issue paying your receiving wallet
|
||||
<ul>
|
||||
<li>if the zap is small and you don't have a direct channel to SN, the routing fee may exceed SN's 3% max fee</li>
|
||||
<li>check your <Link href='/wallets/logs'>wallet logs</Link> for clues</li>
|
||||
<li>if you have questions about the errors in your wallet logs, mention the error in the <Link href='/daily'>saloon</Link></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>some zaps might be smaller than your configured receiving dust limit
|
||||
<ul>
|
||||
<li>you can configure your dust limit in your <Link href='/settings'>settings</Link></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</Info>
|
||||
)
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import InfoIcon from '@/svgs/information-fill.svg'
|
||||
import { useShowModal } from './modal'
|
||||
import { useShowModal } from '../modal'
|
||||
|
||||
export default function Info ({ children, label, iconClassName = 'fill-theme-color' }) {
|
||||
export default function Info ({ children, size = 18, label, iconClassName = 'fill-theme-color' }) {
|
||||
const showModal = useShowModal()
|
||||
|
||||
return (
|
||||
@ -11,10 +11,10 @@ export default function Info ({ children, label, iconClassName = 'fill-theme-col
|
||||
e.preventDefault()
|
||||
showModal(onClose => children)
|
||||
}}
|
||||
className='d-flex align-items-center pointer'
|
||||
className='pointer d-flex align-items-center'
|
||||
>
|
||||
<InfoIcon
|
||||
width={18} height={18} className={`${iconClassName} mx-1`}
|
||||
width={size} height={size} className={`${iconClassName} mx-1`}
|
||||
/>
|
||||
{label && <small className='text-muted'>{label}</small>}
|
||||
</div>
|
18
components/info/reward-sats.js
Normal file
18
components/info/reward-sats.js
Normal file
@ -0,0 +1,18 @@
|
||||
import Link from 'next/link'
|
||||
import Info from '.'
|
||||
|
||||
export default function RewardSatsInfo (props) {
|
||||
return (
|
||||
<Info {...props}>
|
||||
<h6>Where did my sats come from?</h6>
|
||||
<ul className='line-height-md'>
|
||||
<li>you may have sats from before <Link href='/items/835465'>SN went not-custodial</Link></li>
|
||||
<li>sats also come from <Link href='/rewards'>daily rewards</Link> and territory revenue
|
||||
<ul>
|
||||
<li>you can configure these sats to autowithdraw by attaching an <Link href='/wallets'>external receiving wallet</Link></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</Info>
|
||||
)
|
||||
}
|
@ -126,7 +126,8 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
||||
variables: {
|
||||
id: item.id,
|
||||
sats: Number(amount),
|
||||
act
|
||||
act,
|
||||
hasSendWallet: wallets.length > 0
|
||||
},
|
||||
optimisticResponse: me
|
||||
? {
|
||||
@ -179,9 +180,11 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
||||
</Form>)
|
||||
}
|
||||
|
||||
function modifyActCache (cache, { result, invoice }) {
|
||||
function modifyActCache (cache, { result, invoice }, me) {
|
||||
if (!result) return
|
||||
const { id, sats, act } = result
|
||||
const p2p = invoice?.invoiceForward
|
||||
|
||||
cache.modify({
|
||||
id: `Item:${id}`,
|
||||
fields: {
|
||||
@ -191,12 +194,24 @@ function modifyActCache (cache, { result, invoice }) {
|
||||
}
|
||||
return existingSats
|
||||
},
|
||||
credits (existingCredits = 0) {
|
||||
if (act === 'TIP' && !p2p) {
|
||||
return existingCredits + sats
|
||||
}
|
||||
return existingCredits
|
||||
},
|
||||
meSats: (existingSats = 0) => {
|
||||
if (act === 'TIP') {
|
||||
if (act === 'TIP' && me) {
|
||||
return existingSats + sats
|
||||
}
|
||||
return existingSats
|
||||
},
|
||||
meCredits: (existingCredits = 0) => {
|
||||
if (act === 'TIP' && !p2p && me) {
|
||||
return existingCredits + sats
|
||||
}
|
||||
return existingCredits
|
||||
},
|
||||
meDontLikeSats: (existingSats = 0) => {
|
||||
if (act === 'DONT_LIKE_THIS') {
|
||||
return existingSats + sats
|
||||
@ -219,6 +234,8 @@ function modifyActCache (cache, { result, invoice }) {
|
||||
function updateAncestors (cache, { result, invoice }) {
|
||||
if (!result) return
|
||||
const { id, sats, act, path } = result
|
||||
const p2p = invoice?.invoiceForward
|
||||
|
||||
if (act === 'TIP') {
|
||||
// update all ancestors
|
||||
path.split('.').forEach(aId => {
|
||||
@ -226,6 +243,12 @@ function updateAncestors (cache, { result, invoice }) {
|
||||
cache.modify({
|
||||
id: `Item:${aId}`,
|
||||
fields: {
|
||||
commentCredits (existingCommentCredits = 0) {
|
||||
if (p2p) {
|
||||
return existingCommentCredits
|
||||
}
|
||||
return existingCommentCredits + sats
|
||||
},
|
||||
commentSats (existingCommentSats = 0) {
|
||||
return existingCommentSats + sats
|
||||
}
|
||||
@ -237,6 +260,7 @@ function updateAncestors (cache, { result, invoice }) {
|
||||
}
|
||||
|
||||
export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
|
||||
const { me } = useMe()
|
||||
// because the mutation name we use varies,
|
||||
// we need to extract the result/invoice from the response
|
||||
const getPaidActionResult = data => Object.values(data)[0]
|
||||
@ -253,7 +277,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
|
||||
update: (cache, { data }) => {
|
||||
const response = getPaidActionResult(data)
|
||||
if (!response) return
|
||||
modifyActCache(cache, response)
|
||||
modifyActCache(cache, response, me)
|
||||
options?.update?.(cache, { data })
|
||||
},
|
||||
onPayError: (e, cache, { data }) => {
|
||||
@ -261,7 +285,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
|
||||
if (!response || !response.result) return
|
||||
const { result: { sats } } = response
|
||||
const negate = { ...response, result: { ...response.result, sats: -1 * sats } }
|
||||
modifyActCache(cache, negate)
|
||||
modifyActCache(cache, negate, me)
|
||||
options?.onPayError?.(e, cache, { data })
|
||||
},
|
||||
onPaid: (cache, { data }) => {
|
||||
@ -286,7 +310,7 @@ export function useZap () {
|
||||
// add current sats to next tip since idempotent zaps use desired total zap not difference
|
||||
const sats = nextTip(meSats, { ...me?.privates })
|
||||
|
||||
const variables = { id: item.id, sats, act: 'TIP' }
|
||||
const variables = { id: item.id, sats, act: 'TIP', hasSendWallet: wallets.length > 0 }
|
||||
const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } }
|
||||
|
||||
try {
|
||||
|
@ -30,6 +30,39 @@ import classNames from 'classnames'
|
||||
import SubPopover from './sub-popover'
|
||||
import useCanEdit from './use-can-edit'
|
||||
|
||||
function itemTitle (item) {
|
||||
let title = ''
|
||||
title += numWithUnits(item.upvotes, {
|
||||
abbreviate: false,
|
||||
unitSingular: 'zapper',
|
||||
unitPlural: 'zappers'
|
||||
})
|
||||
if (item.sats) {
|
||||
title += ` \\ ${numWithUnits(item.sats - item.credits, { abbreviate: false })}`
|
||||
}
|
||||
if (item.credits) {
|
||||
title += ` \\ ${numWithUnits(item.credits, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })}`
|
||||
}
|
||||
if (item.mine) {
|
||||
title += ` (${numWithUnits(item.meSats, { abbreviate: false })} to post)`
|
||||
} else if (item.meSats || item.meDontLikeSats || item.meAnonSats) {
|
||||
const satSources = []
|
||||
if (item.meAnonSats || (item.meSats || 0) - (item.meCredits || 0) > 0) {
|
||||
satSources.push(`${numWithUnits((item.meSats || 0) + (item.meAnonSats || 0) - (item.meCredits || 0), { abbreviate: false })}`)
|
||||
}
|
||||
if (item.meCredits) {
|
||||
satSources.push(`${numWithUnits(item.meCredits, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })}`)
|
||||
}
|
||||
if (item.meDontLikeSats) {
|
||||
satSources.push(`${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`)
|
||||
}
|
||||
if (satSources.length) {
|
||||
title += ` (${satSources.join(' & ')} from me)`
|
||||
}
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
export default function ItemInfo ({
|
||||
item, full, commentsText = 'comments',
|
||||
commentTextSingular = 'comment', className, embellishUser, extraInfo, edit, toggleEdit, editText,
|
||||
@ -62,16 +95,7 @@ export default function ItemInfo ({
|
||||
<div className={className || `${styles.other}`}>
|
||||
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
|
||||
<>
|
||||
<span title={`from ${numWithUnits(item.upvotes, {
|
||||
abbreviate: false,
|
||||
unitSingular: 'stacker',
|
||||
unitPlural: 'stackers'
|
||||
})} ${item.mine
|
||||
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
|
||||
: `(${numWithUnits(meSats, { abbreviate: false })}${item.meDontLikeSats
|
||||
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
|
||||
: ''} from me)`} `}
|
||||
>
|
||||
<span title={itemTitle(item)}>
|
||||
{numWithUnits(item.sats)}
|
||||
</span>
|
||||
<span> \ </span>
|
||||
@ -228,12 +252,16 @@ function InfoDropdownItem ({ item }) {
|
||||
<div>{item.createdAt}</div>
|
||||
<div>cost</div>
|
||||
<div>{item.cost}</div>
|
||||
<div>sats</div>
|
||||
<div>{item.sats}</div>
|
||||
<div>stacked</div>
|
||||
<div>{item.sats - item.credits} sats / {item.credits} ccs</div>
|
||||
<div>stacked (comments)</div>
|
||||
<div>{item.commentSats - item.commentCredits} sats / {item.commentCredits} ccs</div>
|
||||
{me && (
|
||||
<>
|
||||
<div>sats from me</div>
|
||||
<div>{item.meSats}</div>
|
||||
<div>from me</div>
|
||||
<div>{item.meSats - item.meCredits} sats / {item.meCredits} ccs</div>
|
||||
<div>downsats from me</div>
|
||||
<div>{item.meDontLikeSats}</div>
|
||||
</>
|
||||
)}
|
||||
<div>zappers</div>
|
||||
|
@ -164,6 +164,8 @@ a.link:visited {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
column-gap: 1rem;
|
||||
line-height: 1.25;
|
||||
row-gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* .itemJob .hunk {
|
||||
|
@ -109,6 +109,7 @@ export default function useModal () {
|
||||
|
||||
const showModal = useCallback(
|
||||
(getContent, options) => {
|
||||
document.activeElement?.blur()
|
||||
const ref = { node: getContent(onClose, setOptions), options }
|
||||
if (options?.replaceModal) {
|
||||
modalStack.current = [ref]
|
||||
|
@ -6,12 +6,11 @@ import BackArrow from '../../svgs/arrow-left-line.svg'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Price from '../price'
|
||||
import SubSelect from '../sub-select'
|
||||
import { USER_ID, BALANCE_LIMIT_MSATS } from '../../lib/constants'
|
||||
import { USER_ID } from '../../lib/constants'
|
||||
import Head from 'next/head'
|
||||
import NoteIcon from '../../svgs/notification-4-fill.svg'
|
||||
import { useMe } from '../me'
|
||||
import HiddenWalletSummary from '../hidden-wallet-summary'
|
||||
import { abbrNum, msatsToSats } from '../../lib/format'
|
||||
import { abbrNum } from '../../lib/format'
|
||||
import { useServiceWorker } from '../serviceworker'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import Badges from '../badge'
|
||||
@ -25,7 +24,7 @@ import { useHasNewNotes } from '../use-has-new-notes'
|
||||
import { useWallets } from '@/wallets/index'
|
||||
import SwitchAccountList, { useAccounts } from '@/components/account'
|
||||
import { useShowModal } from '@/components/modal'
|
||||
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
export function Brand ({ className }) {
|
||||
return (
|
||||
<Link href='/' passHref legacyBehavior>
|
||||
@ -140,21 +139,24 @@ export function NavNotifications ({ className }) {
|
||||
|
||||
export function WalletSummary () {
|
||||
const { me } = useMe()
|
||||
if (!me) return null
|
||||
if (me.privates?.hideWalletBalance) {
|
||||
return <HiddenWalletSummary abbreviate fixedWidth />
|
||||
}
|
||||
return `${abbrNum(me.privates?.sats)}`
|
||||
if (!me || me.privates?.sats === 0) return null
|
||||
return (
|
||||
<span
|
||||
className='text-monospace'
|
||||
title={`${numWithUnits(me.privates?.credits, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })}`}
|
||||
>
|
||||
{`${abbrNum(me.privates?.sats)}`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavWalletSummary ({ className }) {
|
||||
const { me } = useMe()
|
||||
const walletLimitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
|
||||
|
||||
return (
|
||||
<Nav.Item className={className}>
|
||||
<Link href='/wallet' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='wallet' className={`${walletLimitReached ? 'text-warning' : 'text-success'} text-monospace px-0 text-nowrap`}>
|
||||
<Link href='/credits' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='credits' className='text-success text-monospace px-0 text-nowrap'>
|
||||
<WalletSummary me={me} />
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
@ -194,8 +196,11 @@ export function MeDropdown ({ me, dropNavKey }) {
|
||||
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
|
||||
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
|
||||
</Link>
|
||||
<Link href='/wallet' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
|
||||
<Link href='/wallets' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
|
||||
</Link>
|
||||
<Link href='/credits' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
|
||||
</Link>
|
||||
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item>
|
||||
|
@ -59,8 +59,11 @@ export default function OffCanvas ({ me, dropNavKey }) {
|
||||
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
|
||||
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
|
||||
</Link>
|
||||
<Link href='/wallet' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
|
||||
<Link href='/wallets' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
|
||||
</Link>
|
||||
<Link href='/credits' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
|
||||
</Link>
|
||||
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item>
|
||||
|
@ -43,6 +43,7 @@ import { useToast } from './toast'
|
||||
import classNames from 'classnames'
|
||||
import HolsterIcon from '@/svgs/holster.svg'
|
||||
import SaddleIcon from '@/svgs/saddle.svg'
|
||||
import CCInfo from './info/cc'
|
||||
|
||||
function Notification ({ n, fresh }) {
|
||||
const type = n.__typename
|
||||
@ -535,9 +536,27 @@ function Referral ({ n }) {
|
||||
)
|
||||
}
|
||||
|
||||
function stackedText (item) {
|
||||
let text = ''
|
||||
if (item.sats - item.credits > 0) {
|
||||
text += `${numWithUnits(item.sats - item.credits, { abbreviate: false })}`
|
||||
|
||||
if (item.credits > 0) {
|
||||
text += ' and '
|
||||
}
|
||||
}
|
||||
if (item.credits > 0) {
|
||||
text += `${numWithUnits(item.credits, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })}`
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function Votification ({ n }) {
|
||||
let forwardedSats = 0
|
||||
let ForwardedUsers = null
|
||||
let stackedTextString
|
||||
let forwardedTextString
|
||||
if (n.item.forwards?.length) {
|
||||
forwardedSats = Math.floor(n.earnedSats * n.item.forwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100)
|
||||
ForwardedUsers = () => n.item.forwards.map((fwd, i) =>
|
||||
@ -547,16 +566,25 @@ function Votification ({ n }) {
|
||||
</Link>
|
||||
{i !== n.item.forwards.length - 1 && ' '}
|
||||
</span>)
|
||||
stackedTextString = numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })
|
||||
forwardedTextString = numWithUnits(forwardedSats, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })
|
||||
} else {
|
||||
stackedTextString = stackedText(n.item)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<NoteHeader color='success'>
|
||||
your {n.item.title ? 'post' : 'reply'} stacked {numWithUnits(n.earnedSats, { abbreviate: false })}
|
||||
{n.item.forwards?.length > 0 &&
|
||||
<>
|
||||
{' '}and forwarded {numWithUnits(forwardedSats, { abbreviate: false })} to{' '}
|
||||
<ForwardedUsers />
|
||||
</>}
|
||||
<span className='d-inline-flex'>
|
||||
<span>
|
||||
your {n.item.title ? 'post' : 'reply'} stacked {stackedTextString}
|
||||
{n.item.forwards?.length > 0 &&
|
||||
<>
|
||||
{' '}and forwarded {forwardedTextString} to{' '}
|
||||
<ForwardedUsers />
|
||||
</>}
|
||||
</span>
|
||||
{n.item.credits > 0 && <CCInfo size={16} />}
|
||||
</span>
|
||||
</NoteHeader>
|
||||
<NoteItem item={n.item} />
|
||||
</>
|
||||
@ -567,7 +595,10 @@ function ForwardedVotification ({ n }) {
|
||||
return (
|
||||
<>
|
||||
<NoteHeader color='success'>
|
||||
you were forwarded {numWithUnits(n.earnedSats, { abbreviate: false })} from
|
||||
<span className='d-inline-flex'>
|
||||
you were forwarded {numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'CC', unitPlural: 'CCs' })}
|
||||
<CCInfo size={16} />
|
||||
</span>
|
||||
</NoteHeader>
|
||||
<NoteItem item={n.item} />
|
||||
</>
|
||||
|
@ -9,6 +9,7 @@ import { useRoot } from './root'
|
||||
import { ActCanceledError, useAct } from './item-act'
|
||||
import { useLightning } from './lightning'
|
||||
import { useToast } from './toast'
|
||||
import { useSendWallets } from '@/wallets/index'
|
||||
|
||||
export const payBountyCacheMods = {
|
||||
onPaid: (cache, { data }) => {
|
||||
@ -49,7 +50,9 @@ export default function PayBounty ({ children, item }) {
|
||||
const root = useRoot()
|
||||
const strike = useLightning()
|
||||
const toaster = useToast()
|
||||
const variables = { id: item.id, sats: root.bounty, act: 'TIP' }
|
||||
const wallets = useSendWallets()
|
||||
|
||||
const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet: wallets.length > 0 }
|
||||
const act = useAct({
|
||||
variables,
|
||||
optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } },
|
||||
|
@ -158,9 +158,10 @@ export const ServiceWorkerProvider = ({ children }) => {
|
||||
// since (a lot of) browsers don't support the pushsubscriptionchange event,
|
||||
// we sync with server manually by checking on every page reload if the push subscription changed.
|
||||
// see https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
|
||||
navigator?.serviceWorker?.controller?.postMessage?.({ action: STORE_OS, os: detectOS() })
|
||||
logger.info('sent STORE_OS to service worker: ', detectOS())
|
||||
navigator?.serviceWorker?.controller?.postMessage?.({ action: SYNC_SUBSCRIPTION })
|
||||
logger.info('sent SYNC_SUBSCRIPTION to service worker')
|
||||
navigator?.serviceWorker?.controller?.postMessage?.({ action: STORE_OS, os: detectOS() })
|
||||
}, [registration, permission.notification])
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
|
@ -36,13 +36,9 @@ export default function useInvoice () {
|
||||
return { invoice: data.invoice, check: that(data.invoice) }
|
||||
}, [client])
|
||||
|
||||
const cancel = useCallback(async ({ hash, hmac }) => {
|
||||
if (!hash || !hmac) {
|
||||
throw new Error('missing hash or hmac')
|
||||
}
|
||||
|
||||
const cancel = useCallback(async ({ hash, hmac }, { userCancel = false } = {}) => {
|
||||
console.log('canceling invoice:', hash)
|
||||
const { data } = await cancelInvoice({ variables: { hash, hmac } })
|
||||
const { data } = await cancelInvoice({ variables: { hash, hmac, userCancel } })
|
||||
return data.cancelInvoice
|
||||
}, [cancelInvoice])
|
||||
|
||||
|
@ -20,7 +20,7 @@ export default function useQrPayment () {
|
||||
let paid
|
||||
const cancelAndReject = async (onClose) => {
|
||||
if (!paid && cancelOnClose) {
|
||||
const updatedInv = await invoice.cancel(inv)
|
||||
const updatedInv = await invoice.cancel(inv, { userCancel: true })
|
||||
reject(new InvoiceCanceledError(updatedInv))
|
||||
}
|
||||
resolve(inv)
|
||||
|
@ -44,7 +44,7 @@ export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnte
|
||||
: <Card.Title className={styles.walletLogo}>{wallet.def.card.title}</Card.Title>}
|
||||
</div>
|
||||
</Card.Body>
|
||||
<Link href={`/settings/wallets/${wallet.def.name}`}>
|
||||
<Link href={`/wallets/${wallet.def.name}`}>
|
||||
<Card.Footer className={styles.attach}>
|
||||
{isConfigured(wallet)
|
||||
? <>configure<Gear width={14} height={14} /></>
|
||||
|
@ -281,7 +281,7 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
|
||||
|
||||
useEffect(() => {
|
||||
// only fetch new logs if we are on a page that uses logs
|
||||
const needLogs = router.asPath.startsWith('/settings/wallets') || router.asPath.startsWith('/wallet/logs')
|
||||
const needLogs = router.asPath.startsWith('/wallets')
|
||||
if (!me || !needLogs) return
|
||||
|
||||
let timeout
|
||||
|
686
docs/user/faq.md
Normal file
686
docs/user/faq.md
Normal file
@ -0,0 +1,686 @@
|
||||
---
|
||||
title: Frequently Asked Questions
|
||||
id: 349
|
||||
sub: meta
|
||||
---
|
||||
|
||||
# Stacker News FAQ
|
||||
|
||||
To quickly browse through this FAQ page, click the chapters icon in the top-right corner. This will let you scroll through all FAQ chapter titles or search for a particular topic within this page.
|
||||
|
||||
---
|
||||
|
||||
## New Stackers Start Here
|
||||
|
||||
‎
|
||||
##### What is Stacker News?
|
||||
|
||||
Stacker News is a forum (like Reddit or Hacker News) where you can earn sats for creating or curating content. Rather than collecting “upvotes” that are not redeemable or transferable on Reddit or Hacker News, on Stacker News you can earn sats.
|
||||
|
||||
‎
|
||||
##### What Are Sats?
|
||||
|
||||
Sats are the smallest denomination of Bitcoin. Just like there are 100 pennies in 1 dollar, there are 100,000,000 sats in 1 Bitcoin. On Stacker News, all Bitcoin payments and balances are denominated in sats.
|
||||
|
||||
‎
|
||||
##### Do I Need Bitcoin to Use Stacker News?
|
||||
|
||||
No. Every new stacker can comment for free (with limited visibility) while they earn their first few sats. After a stacker has started earning sats for their content, subsequent posts and comments will incur a small fee to prevent spam and to encourage quality contributions. Many stackers earn enough sats from their posts and comments to continue posting on the site indefinitely without ever depositing their own sats.
|
||||
|
||||
Post and comment fees vary depending on the [territory](https://stacker.news/faq#stacker-news-territories).
|
||||
|
||||
‎
|
||||
##### Why Is My Wallet Balance Going Up?
|
||||
|
||||
When other stackers [zap](https://stacker.news/faq#zapping-on-stacker-news) your posts and comments, those sats go to you. Stackers who are actively contributing content and sats also earn extra sats as a daily reward. These sats come from the revenue generated by Stacker News from posting/commenting fees and boost fees.
|
||||
|
||||
-----
|
||||
|
||||
## Creating an Account
|
||||
|
||||
‎
|
||||
##### How Do I Create a Stacker News Account?
|
||||
|
||||
The most private way to create a Stacker News account is by logging in with one of the Lightning wallets listed below.
|
||||
|
||||
Lightning wallets for logging in to Stacker News:
|
||||
|
||||
- Alby
|
||||
- Balance of Satoshis
|
||||
- Blixt
|
||||
- Breez
|
||||
- Coinos
|
||||
- LNbits
|
||||
- LNtxbot
|
||||
- Phoenix
|
||||
- SeedAuth
|
||||
- SeedAuthExtension
|
||||
- SimpleBitcoinWallet
|
||||
- ThunderHub
|
||||
- Zap Desktop
|
||||
- Zeus
|
||||
|
||||
Alternatively, new stackers can set up an account by linking their email, Nostr, Github, or X accounts.
|
||||
|
||||
‎
|
||||
##### How Do I Login With Lightning?
|
||||
|
||||
To login with Lightning:
|
||||
|
||||
1. Click [Login](/login)
|
||||
2. Select [Login with Lightning](/login?type=lightning)
|
||||
3. Open one of the Lightning wallets listed above
|
||||
4. Scan the QR code that appears on Stacker News
|
||||
5. Confirm your log in attempt on your Lightning wallet
|
||||
|
||||
‎
|
||||
##### Can I Use Multiple Login Methods?
|
||||
|
||||
Yes.
|
||||
|
||||
Once you’re logged in, follow these steps to link other authentication methods:
|
||||
1. Click your username
|
||||
2. Click [settings](/settings)
|
||||
3. Scroll down to link other authentication methods
|
||||
|
||||
Once you’ve linked another authentication method to your account, you’ll be able to access your account on any device using any one of your linked authentication methods.
|
||||
|
||||
‎
|
||||
##### Why Should I Log In With Lightning?
|
||||
|
||||
Logging in with Lightning is the most private method of logging in to Stacker News.
|
||||
|
||||
Rather than entering an email address, or linking your X or Github accounts, you can simply scan a QR code with your Lightning wallet or use a Lightning web wallet like Alby which enables desktop stackers to log in with a single click.
|
||||
|
||||
‎
|
||||
##### How Do I Set a Stacker News Username?
|
||||
|
||||
When setting up an account, Stacker News will automatically create a username for you.
|
||||
|
||||
To change your username:
|
||||
|
||||
1. Click your username (it's in the top-right corner of your screen)
|
||||
2. Select profile
|
||||
3. Click ‘edit nym’
|
||||
|
||||
---
|
||||
|
||||
## Funding Your Account
|
||||
|
||||
‎
|
||||
##### How Do I Fund My Stacker News Wallet?
|
||||
|
||||
There are three ways to fund your Stacker News account:
|
||||
|
||||
1. By QR code
|
||||
2. By Lightning Address
|
||||
3. By sharing great content
|
||||
|
||||
‎
|
||||
###### QR code
|
||||
|
||||
1. Click your username
|
||||
2. Click [wallet](/wallet)
|
||||
3. Click fund
|
||||
4. Enter a payment amount
|
||||
5. Generate an invoice on Stacker News
|
||||
6. Pay the invoice on your Lightning wallet
|
||||
|
||||
‎
|
||||
###### Lightning Address
|
||||
|
||||
1. Click your username
|
||||
2. Open a wallet that offers Lightning Address support
|
||||
3. Enter your Stacker News Lightning Address on your wallet
|
||||
4. Pay any amount to fund your Stacker News account
|
||||
|
||||
‎
|
||||
###### Sharing great content
|
||||
|
||||
Every new stacker gets free comments (with limited visibility) to get started on Stacker News. Many stackers have earned enough sats from their first few posts and comments to continue posting on the site indefinitely without ever depositing their own sats.
|
||||
|
||||
‎
|
||||
##### What Is a Lightning Address?
|
||||
|
||||
A Lightning Address is just like an email address, but for your Bitcoin.
|
||||
|
||||
It is a simple tool that anyone can use to send Bitcoin without scanning QR codes or copying and pasting invoices between wallets.
|
||||
|
||||
For more on how Lightning Addresses work, [click here](https://lightningaddress.com/).
|
||||
|
||||
‎
|
||||
##### Where Is My Stacker News Lightning Address?
|
||||
|
||||
All stackers get Lightning addresses, which follow the format of username@stacker.news.
|
||||
|
||||
Your Lightning address can also be found on your profile page, highlighted with a yellow button and a Lightning bolt icon.
|
||||
|
||||
‎
|
||||
##### How Do I See My Account Balance?
|
||||
|
||||
When logged in, your wallet balance is the number shown in the top-right corner of your screen.
|
||||
|
||||
Clicking your wallet balance allows you to fund, withdraw, or view your past transactions.
|
||||
|
||||
‎
|
||||
##### How Do I See My Transaction History?
|
||||
|
||||
To see your full history of Stacker News transactions:
|
||||
|
||||
1. Click your wallet balance in the top-right corner of your screen
|
||||
2. Click [Wallet History](https://stacker.news/satistics?inc=invoice,withdrawal,stacked,spent)
|
||||
3. Select which data you would like to see from the top menu
|
||||
|
||||
The buttons on your wallet history page allow you to view and filter your past funding invoices, withdrawals, as well as the transactions where you stacked sats or spent sats on Stacker News.
|
||||
|
||||
-----
|
||||
|
||||
## Posting on Stacker News
|
||||
|
||||
‎
|
||||
##### How Do I Post?
|
||||
|
||||
To submit a post, click the Post button in the nav bar.
|
||||
|
||||
Each post has a small fixed fee as a measure to limit spam, and to encourage stackers to post quality content.
|
||||
|
||||
There are a few different types of posts stackers can make on Stacker News, including links, discussions, polls, and bounties.
|
||||
|
||||
- Link posts require a title and a URL (stackers can optionally include a discussion prompt)
|
||||
- Discussion posts require a title and a discussion prompt (stackers can optionally add links to their discussion prompt)
|
||||
- Poll posts require a title and at least two poll options to choose from
|
||||
- Bounty posts require a title, prompt, and a bounty amount to be paid on task completion
|
||||
|
||||
‎
|
||||
##### How Do I Comment?
|
||||
|
||||
To comment on a post:
|
||||
|
||||
1. Click the title of the post you want to comment on
|
||||
2. Submit your comment in the text box below the post
|
||||
|
||||
To reply to a comment:
|
||||
|
||||
1. Click reply beneath the comment you want to reply to
|
||||
2. Submit your comment in the text box below the comment
|
||||
|
||||
‎
|
||||
##### How Do Posting Fees Work?
|
||||
Post and comment fees vary depending on a few factors.
|
||||
|
||||
First, territory owners have the ability to set their own post and comment fees.
|
||||
|
||||
Additionally, fees increase by 10x for repetitive posts and self-reply comments to prevent spam.
|
||||
|
||||
As an example, if it costs 10 sats for a stacker to make a post in a territory, it will cost 100 sats if they make a second post within 10 minutes of their first post. If they post a third time within 10 minutes of their first one, it will cost 1,000 sats.
|
||||
|
||||
This 10x fee escalation continues until 10 minutes have elapsed, and will reset to a fee of 10 sats when the stacker goes 10 minutes or more without posting or replying to themselves in a comment thread.
|
||||
|
||||
This 10 minute fee escalation rule does not apply to stackers who are replying to other stackers, only those who repetitively post or reply to themselves within a single thread.
|
||||
|
||||
There are also fees for uploads but your first 250 MB within 24 hours are free. After that, every upload will cost 10 sats until you reach 500 MB. Then the fee is raised to 100 sats until 1 GB after which every upload will cost 1,000 sats. After 24 hours, you can upload 250 MB for free again. Uploads without being logged in always cost 100 sats.
|
||||
|
||||
Upload fees are applied when you submit your post or comment. Uploaded content that isn't used within 24 hours in a post or comment is deleted.
|
||||
|
||||
‎
|
||||
##### What Is a Boost?
|
||||
|
||||
Boosts allow stackers to increase the ranking of their post upon creation to give their content more visibility.
|
||||
|
||||
‎
|
||||
##### How Do I Earn Sats on Stacker News?
|
||||
|
||||
Stackers reward each other for their contributions by zapping them with sats.
|
||||
|
||||
To start earning sats, you can share interesting links, discussion prompts, or comments with the community.
|
||||
|
||||
Beyond the direct payments from other stackers, Stacker News also uses the revenue it generates from its job board, boost fees, post fees, and stacker donations to reward stackers that contributed to the site with even more sats.
|
||||
|
||||
Every day, Stacker News rewards either creators or zappers with a daily reward. These rewards go to stackers who either created or zapped one or more of the top 33% of posts and comments from the previous day. The rewards scale with the ranking of the content as determined by other stackers.
|
||||
|
||||
Finally, Stacker News also rewards stackers with sats for referring new stackers to the platform. To read more about the Stacker News referral program, click [here](https://stacker.news/items/349#how-does-the-stacker-news-referral-program-work).
|
||||
|
||||
‎
|
||||
##### How Do I Format Posts on Stacker News?
|
||||
|
||||
Stacker News uses [github flavored markdown](https://guides.github.com/features/mastering-markdown/) for styling all posts and comments.
|
||||
|
||||
You can use any of the following elements in your content:
|
||||
|
||||
- Headings
|
||||
- Blockquotes
|
||||
- Unordered Lists
|
||||
- Ordered Lists
|
||||
- Inline code with syntax highlighting
|
||||
- Tables
|
||||
- Text Links
|
||||
- Line Breaks
|
||||
- Subscript or Superscript
|
||||
|
||||
In addition, stackers can tag other stackers with the @ symbol like this: @sn. Stackers can also refer to different territories with the ~ symbol like this: ~jobs.
|
||||
|
||||
‎
|
||||
##### How Do I Post Images or Videos on Stacker News?
|
||||
|
||||
There are two ways to post images or videos:
|
||||
|
||||
1. By pasting a URL to an image or video
|
||||
2. By uploading an image or video
|
||||
|
||||
If you have a URL, you can simply paste it into any textbox. Once your link is pasted into the textbox of a post or comment, it will automatically be rendered as an image or video when you preview or post.
|
||||
|
||||
To upload files, click the upload icon on the top-right corner of the textbox. This will open a file explorer where you can select the files you want to upload (or multiple). We currently support following file types:
|
||||
|
||||
- image/gif
|
||||
- image/heic
|
||||
- image/png
|
||||
- image/jpeg
|
||||
- image/webp
|
||||
- video/mp4
|
||||
- video/mpeg
|
||||
- video/webm
|
||||
|
||||
Uploaded content that isn't used within 24 hours in a SN post or comment is deleted.
|
||||
|
||||
As explained in the [section about posting fees](https://stacker.news/faq#how-do-posting-fees-work), fees might apply for uploads.
|
||||
|
||||
To expand an image on Stacker News, click the image. Clicking it again will shrink it back to its original size.
|
||||
|
||||
If you are trying to post images from Twitter on Stacker News, make sure you have selected the tweet's image URL, and not the tweet URL itself.
|
||||
|
||||
To find the image URL of a twitter photo, right-click the image on Twitter, select "Open In New Tab", and copy that URL.
|
||||
|
||||
‎
|
||||
##### Stacker News Shortcuts
|
||||
|
||||
Stacker News supports a handful of useful keyboard shortcuts for saving time when creating content:
|
||||
|
||||
`ctrl+enter`: submit any post/comment/form
|
||||
`ctrl+k`: link in markdown fields
|
||||
`ctrl+i`: italics in markdown fields
|
||||
`ctrl+b`: bold in markdown fields
|
||||
`ctrl+alt+tab`: real tab in markdown fields
|
||||
|
||||
-----
|
||||
|
||||
## Stacker News Territories
|
||||
|
||||
‎
|
||||
##### What are Territories?
|
||||
|
||||
Territories are communities on Stacker News. Each territory has an owner who acts as a steward of the community, and anyone can post content to the territory that best fits the topic of their post.
|
||||
|
||||
When Stacker News first launched, much of the discussion focused exclusively on Bitcoin. However, the launch of territories means anyone can now create a thriving community on Stacker News to discuss any topic.
|
||||
|
||||
‎
|
||||
##### Can Anyone Start a Territory?
|
||||
|
||||
Anyone can start a territory by clicking the dropdown menu next to the logo on the homepage, scrolling to the bottom of the list, and clicking [create](https://stacker.news/territory). Stackers can also create as many territories as they want.
|
||||
|
||||
‎
|
||||
##### How Much Does It Cost to Start a Territory?
|
||||
|
||||
Starting a territory costs either 100k sats/month, 1m sats/year, or 3m sats as a one-time payment.
|
||||
|
||||
If a territory owners chooses either the monthly or yearly payment options, they can select 'auto-renew' so that Stacker News is automatically paid the territory fee each month or year. If a territory owner doesn't select 'auto-renew', they will get a notification to pay an invoice within 5 days after the end of their month or year to keep their territory.
|
||||
|
||||
If you later change your mind, your payment for the current period is included in the new cost. This means that if you go from monthly to yearly payments for example, we will charge you 900k instead of 1m sats.
|
||||
|
||||
‎
|
||||
##### Can Territory Owners Earn Sats?
|
||||
|
||||
Yes, territory owners earn 70% of all fees generated by content in their specific territory. This means territory owners earn 7% of all sats zapped within their territory, as well as 70% of all sats paid as boosts or posting and commenting costs within their territory. These rewards are paid to territory owners each day as part of the Stacker News daily rewards.
|
||||
|
||||
The remaining 30% of fees generated by content in a given territory is paid to the Stacker News daily rewards pool, which rewards the best contributors on the site each day.
|
||||
|
||||
‎
|
||||
##### What Variables Do Territory Owners Control?
|
||||
|
||||
Territory owners can set the following variables for their territory:
|
||||
|
||||
- Territory name
|
||||
- Territory description
|
||||
- Minimum posting cost
|
||||
- Allowable post types
|
||||
|
||||
Territory owners can also mark their territory as NSFW or enable moderation. Moderation allows them to outlaw content with one click (see [How Do I Flag Content](https://stacker.news/faq#how-do-i-flag-content-i-dont-like)).
|
||||
|
||||
All territory variables can be updated after creation.
|
||||
|
||||
‎
|
||||
##### What Happens If I No Longer Want My Territory?
|
||||
|
||||
If a territory owner chooses not to renew their territory at the end of their billing period, the territory will be archived. Stackers can still see archived posts and comments, but they will not be able to create new posts or comments until someone takes ownership of the territory.
|
||||
|
||||
-----
|
||||
|
||||
## Discovering Content on Stacker News
|
||||
|
||||
‎
|
||||
##### How Do I Search on Stacker News?
|
||||
|
||||
To search for content on Stacker News, click the magnifying glass located in the navbar. This is a powerful feature that allows stackers to search for posts, comments, and other stackers across the site.
|
||||
|
||||
[Search results](https://stacker.news/search) can be filtered by the following metrics:
|
||||
|
||||
- best match
|
||||
- most recent
|
||||
- most comments
|
||||
- most sats
|
||||
- most votes
|
||||
|
||||
In addition, search results can be segmented over time, showing the relevant results from the past day, week, month, year, or forever.
|
||||
|
||||
Finally, there are some hidden search commands that can further assist you with identifying specific types of content on Stacker News:
|
||||
|
||||
`~territoryname` allows you to search within a specific territory
|
||||
`nym:ausersnym` allows you to search for items from a certain user by replacing `ausernym` with the nym you want to find
|
||||
`url:aurl` allows you to search for certain domain names by replacing `aurl` with a domain name you want to find
|
||||
|
||||
‎
|
||||
##### How Do I Subscribe to Someone on Stacker News?
|
||||
|
||||
If you find a stacker you want to see more content from, you can click their profile and then click the `...` icon next to their photo. There, you can choose to either subscribe to their posts or their comments.
|
||||
|
||||
Once subscribed, you'll get a notification each time they post content.
|
||||
|
||||
‎
|
||||
##### How Do I Subscribe to Posts on Stacker News?
|
||||
|
||||
If you find a post you want to follow along with, click the `...` icon next to the post metadata and select subscribe.
|
||||
|
||||
Once subscribed, you'll get a notification each time someone makes a comment on that post.
|
||||
|
||||
‎
|
||||
##### How Do I Mute on Stacker News?
|
||||
|
||||
If you want to mute a stacker, click the `...` icon next to one of their posts or the `...` icon on their profile page and select mute.
|
||||
|
||||
Once muted, you'll no longer see that stacker's content or get notified if they comment on your content.
|
||||
|
||||
‎
|
||||
##### How Do I Find New Territories on Stacker News?
|
||||
|
||||
Stacker News offers a number of territories, or topic-based collections of content.
|
||||
|
||||
To explore a particular territory on Stacker News, click the dropdown menu next to the Stacker News logo in the navbar and select the topic you'd like to see content on.
|
||||
|
||||
If you want to post content to a particular territory, the territory you're currently browsing will automatically be selected as the territory for your post.
|
||||
|
||||
If you wish to post your content in a different territory, simply select a new one from the dropdown on the post page and fill out your post details there.
|
||||
|
||||
-----
|
||||
|
||||
## Zapping on Stacker News
|
||||
|
||||
‎
|
||||
##### How Do I Zap on Stacker News?
|
||||
|
||||
To send a zap, click the Lightning bolt next to a post or comment. Each click will automatically send your default zap amount to the creator of the post or comment. You can zap a post or comment an unlimited number of times.
|
||||
|
||||
You can also zap any specific number of sats by either changing your default zap amount or by setting a custom zap amount on an individual piece of content.
|
||||
|
||||
‎
|
||||
##### How Do I Change My Default Zap Amount?
|
||||
|
||||
You can change your default zap amount in your settings:
|
||||
|
||||
1. Click your username
|
||||
2. Click [settings](/settings)
|
||||
3. Enter a new default zap amount
|
||||
|
||||
‎
|
||||
##### How Do I Zap a Custom Amount?
|
||||
|
||||
To send a custom zap amount, long-press on the Lightning bolt next to a post or comment until a textbox appears. Then type the number of sats you’d like to zap, and click zap.
|
||||
|
||||
‎
|
||||
##### Turbo Zaps
|
||||
|
||||
Turbo Zaps is an opt-in, experimental feature for improving zapping UX. When enabled in your settings, every Lightning bolt click on a specific post or comment raises your total zap to the next 10x of your default zap amount. If your default zap amount is 1 sat:
|
||||
|
||||
- your first click: 1 sat total zapped
|
||||
- your second click: 10 sats total zapped
|
||||
- your third click: 100 sats total zapped
|
||||
- your fourth click: 1000 sats total zapped
|
||||
- and so on...
|
||||
|
||||
Turbo zaps only escalate your zapping amount when you repeatedly click on the Lightning bolt of a specific post or comment. Zapping a new post or comment will once again start at your default zap amount, and escalate by 10x with every additional click.
|
||||
|
||||
Turbo zaps is a convenient way to modify your zap amounts on the go, rather than relying on a single default amount or a long-press of the Lightning bolt for all your zapping.
|
||||
|
||||
‎
|
||||
##### Do Zaps Help Content Rank Higher?
|
||||
|
||||
Yes. The ranking of an item is affected by:
|
||||
|
||||
- the amount a stacker zaps a post or comment
|
||||
- the trust of the stacker making the zap
|
||||
- the time elapsed since the creation of the item
|
||||
|
||||
Zapping an item with more sats amplifies your trust, giving you more influence on an item's ranking. However, the relationship between sats contributed and a stacker's influence on item ranking is not linear, it's logarithmic.
|
||||
|
||||
The effect a stacker's zap has on an item's ranking is `trust*log10(total zap amount)` where 10 sats = 1 vote, 100 sats = 2, 1000 sats = 3, and so on ... all values in between are valid as well.
|
||||
|
||||
To make this feature sybil resistant, SN takes 30% of zaps and re-distributes them to territory founders and the SN community as part of the daily rewards.
|
||||
|
||||
‎
|
||||
##### Why Should I Zap Posts on Stacker News?
|
||||
|
||||
There are a few reasons to zap posts on Stacker News:
|
||||
|
||||
1. To influence the ranking of content on the site
|
||||
|
||||
Every post and comment is ranked based on the number of people who zapped it and the trust level of each zapping stacker. More zaps from more trusted stackers means more people will see a particular piece of content.
|
||||
|
||||
2. To acknowledge the value of the content other people create (value for value)
|
||||
|
||||
Sending someone a like or an upvote incurs no cost to you, and therefore these metrics can easily be gamed by bots. Sending someone sats incurs a direct cost to you, which gives the recipient a meaningful reward and acts as a clear signal that you found a particular piece of content to be valuable.
|
||||
|
||||
3. To earn trust for identifying good content
|
||||
|
||||
On Stacker News, new stackers start with zero trust and either earn trust by zapping good content or lose trust by zapping bad content.
|
||||
|
||||
‎
|
||||
##### Can I Donate Sats to Stacker News?
|
||||
|
||||
Yes. Every day, Stacker News distributes the revenue it collects from job listings, posting fees, boosts, and donations back to the stackers who made the best contributions on a given day.
|
||||
|
||||
To donate sats directly to the Stacker News rewards pool, or to view the rewards that will be distributed to stackers tomorrow, [click here](https://stacker.news/rewards).
|
||||
|
||||
|
||||
-----
|
||||
|
||||
## Job Board
|
||||
|
||||
‎
|
||||
##### How Do I Post a Job on Stacker News?
|
||||
|
||||
To post a job on Stacker News:
|
||||
|
||||
1. Navigate to the ~jobs territory
|
||||
2. Click post
|
||||
|
||||
Fill out all the details of your job listing, including:
|
||||
|
||||
- Job title
|
||||
- Company name
|
||||
- Location
|
||||
- Description
|
||||
- Application URL or email
|
||||
|
||||
If you wish to promote your job, you can also set a budget for your job listing.
|
||||
|
||||
All promoted jobs are paid for on a sats per minute basis, though you can also see an expected monthly USD price when you set your budget.
|
||||
|
||||
Your budget determines how highly your job listing will rank against other promoted jobs on the Stacker News job board.
|
||||
|
||||
If you want to get more people viewing your job, consider raising your budget above the rate that other employers are paying for their listings.
|
||||
|
||||
If you choose not to promote your job, your listing will be shown in reverse-chronological order, and will be pushed down the job board as new listings appear on Stacker News.
|
||||
|
||||
‎
|
||||
##### How Are Job Listings Ranked on Stacker News?
|
||||
|
||||
Each job is listed in reverse-chronological order on Stacker News, with an option for employers to pay a promotion fee to maintain the ranking of their job listing over time.
|
||||
|
||||
For employers who choose to promote their jobs, the fee amount determines the ranking of a job. The more an employer is willing to pay to advertise their job, the higher their listing will rank.
|
||||
|
||||
If two jobs have identical fees, the first job that was posted will rank higher than the more recent one.
|
||||
|
||||
‎
|
||||
##### Where Do Job Posting Fees Go?
|
||||
|
||||
Stacker News earns revenue from job posting fees, as well as boosts, post and comment fees, and a fee on all zaps on the platform. All of that revenue is then paid back to stackers as daily rewards.
|
||||
|
||||
The sats from the daily rewards go to the stackers who contribute posts and comments each day.
|
||||
|
||||
-----
|
||||
|
||||
## Ranking & Influence on Stacker News
|
||||
|
||||
‎
|
||||
##### What Does The Lightning Bolt Button Do?
|
||||
|
||||
The lightning bolt button next to each post and comment is a tool for stackers to signal that they like what they see.
|
||||
|
||||
The big difference between the Stacker News lightning bolt and the "like" or "upvote" buttons you might find on other sites is that when you press the lightning bolt you're not only raising the ranking of that content, you're also zapping the stacker who created the content with your sats.
|
||||
|
||||
- A grey lightning bolt icon means you haven't zapped the post or comment yet
|
||||
- A colored lightning bolt icon means you have zapped the post or comment (the color changes depending on how much you zap, and you can zap as many times as you like)
|
||||
- If there is no lightning bolt next to a post or comment it means you created the content, and therefore can't zap it
|
||||
|
||||
‎
|
||||
##### How Does Stacker News Rank Content?
|
||||
|
||||
Stacker News uses sats alongside a Web of Trust to rank content and deter Sybil attacks.
|
||||
|
||||
As [explained here](https://stacker.news/items/349#do-zaps-help-content-rank-higher), stackers can send zaps to each other by clicking the lightning bolt next to a post or comment. The zap amounts are one factor that helps determine which content ranks highest on the site, and are weighted by how much the stacker sending the zap is trusted.
|
||||
|
||||
The Stacker News ranking algorithm works as follows:
|
||||
|
||||
- The number of stackers who have zapped an item
|
||||
- Multiplied by the product of the trust score of each stacker and the log value of sats zapped
|
||||
- Divided by a power of the time since a story was submitted
|
||||
- Plus the boost divided by a larger power (relative to un-boosted ranking) of the time since a story was submitted
|
||||
|
||||
The comments made within a post are ranked the same way as top-level Stacker News posts.
|
||||
|
||||
‎
|
||||
##### How Does The Stacker News Web of Trust Work?
|
||||
|
||||
Each stacker has a trust score on Stacker News. New accounts start without any trust, and over time stackers can earn trust by zapping good content, and lose trust by zapping bad content.
|
||||
|
||||
The only consideration that factors into a stacker’s trust level is whether or not they are zapping good content. The zap amount does not impact a stacker's trust.
|
||||
|
||||
In addition, stackers do not lose or gain trust for making posts or comments. Instead, the post and comment fees are the mechanism that incentivizes stackers to only make high quality posts and comments.
|
||||
|
||||
A stacker’s trust is an important factor in determining how much influence their zaps have on the ranking of content, and how much they earn from the daily sat reward pool paid to zappers as [explained here](https://stacker.news/items/349#why-should-i-zap-posts-on-stacker-news).
|
||||
|
||||
‎
|
||||
##### How Do I Flag Content I Don't Like?
|
||||
|
||||
If you see content you don't like, you can click the `...` next to the post or comment to flag it. This is a form of negative feedback that helps Stacker News decide which content should be visible on the site.
|
||||
|
||||
It costs 1 sat to flag content, and doing so doesn't affect your trust or the trust of the stacker who posted the content. Instead, it simply lowers the visibility of the specific item for all stackers on Tenderfoot mode.
|
||||
|
||||
If an item gets flagged by stackers with enough combined trust, it is outlawed and hidden from view for stackers on Tenderfoot mode. If you wish to see this flagged content without any modifications, you can enable Wild West mode in your settings.
|
||||
|
||||
‎
|
||||
##### What is Tenderfoot Mode?
|
||||
|
||||
Tenderfoot mode hides or lowers the visibility of flagged content on Stacker News. This is the default setting for all stackers.
|
||||
|
||||
‎
|
||||
##### What is Wild West Mode?
|
||||
|
||||
Wild West mode allows you to see all content on Stacker News, including content that has been flagged by stackers.
|
||||
|
||||
This unfiltered view doesn't modify the visibility of items on Stacker News based on negative feedback from stackers.
|
||||
|
||||
You can enable Wild West mode in your settings panel.
|
||||
|
||||
‎
|
||||
##### What is sats filter?
|
||||
|
||||
Sats filter allows you to choose how many sats have been "invested" in a post or content for you to see it. "Invested" sats are the sum of posting costs, zapped sats, and boost.
|
||||
|
||||
If you'd like to see all content regardless of investment, set your sats filter to 0.
|
||||
|
||||
-----
|
||||
|
||||
## Notification Settings
|
||||
|
||||
‎
|
||||
##### Where Are My Stacker News Notifications?
|
||||
|
||||
To see your notifications, click the bell icon in the top-right corner of the screen. A red dot next to the bell icon indicates a new notification.
|
||||
|
||||
To change your notification settings:
|
||||
|
||||
1. Click your username
|
||||
2. Click [settings](/settings)
|
||||
3. Update your preferences from the ‘Notify me when…’ section
|
||||
|
||||
‎
|
||||
##### How Do I Create A Bio on Stacker News?
|
||||
|
||||
To fill out your bio:
|
||||
|
||||
1. Click your username
|
||||
2. Click profile
|
||||
3. Click edit bio
|
||||
|
||||
‎
|
||||
##### How Do I View My Past Stacker News Transactions?
|
||||
|
||||
To view your transaction history:
|
||||
|
||||
1. Click your [wallet balance](/wallet) next to your username
|
||||
2. Click wallet history
|
||||
|
||||
-----
|
||||
|
||||
## Other FAQs
|
||||
|
||||
‎
|
||||
##### How Does The Stacker News Referral Program Work?
|
||||
|
||||
For every new stacker you refer, you'll receive:
|
||||
|
||||
- 2.1% of all the sats they earn for their content
|
||||
- 21% of all the sats they spend on boosts or job listings
|
||||
|
||||
Any Stacker News link can be turned into a referral link by appending /r/<your nym>, e.g. `/r/k00b` to the link. This means you can earn sats for sharing Stacker News links on any website, newsletter, video, social media post, or podcast.
|
||||
|
||||
Some examples of referral links using @k00b as an example include:
|
||||
|
||||
`https://stacker.news/r/k00b`
|
||||
`https://stacker.news/items/109473/r/k00b`
|
||||
`https://stacker.news/top/posts/r/k00b?when=week`
|
||||
|
||||
To make referring stackers easy, every post also has a link sharing button in the upper right corner. If you are logged in, copying the link will automatically add your referral code to it.
|
||||
|
||||
For logged in stackers, there is a [dashboard](https://stacker.news/referrals/month) to track your referrals and how much you're earning from them. It's available in the dropdown in the navbar.
|
||||
|
||||
The money paid out to those who refer new stackers comes out of SN's revenue. The referee doesn't pay anything extra, the referrer just gets extra sats as a reward from SN.
|
||||
|
||||
‎
|
||||
##### Where Should I Submit Feature Requests?
|
||||
Ideally on the git repo https://github.com/stackernews/stacker.news/issues. The more background you give on your feature request the better. The hardest part of developing a feature is understanding the problem it solves, all the things that can wrong, etc.
|
||||
|
||||
‎
|
||||
##### Will Stacker News Pay For Contributions?
|
||||
Yes, we pay sats for PRs. Sats will be proportional to the impact of the PR. If there's something you'd like to work on, suggest how much you'd do it for on the issue. If there's something you'd like to work on that isn't already an issue, whether its a bug fix or a new feature, create one.
|
||||
|
||||
‎
|
||||
##### Where Should I Submit Bug Reports?
|
||||
Bug reports can be submitted on our git repo: https://github.com/stackernews/stacker.news/issues.
|
||||
|
||||
‎
|
||||
##### Responsible Disclosure
|
||||
If you find a vulnerability on Stacker News, we would greatly appreciate it if you contact us via hello@stacker.news or [t.me/k00bideh](https://t.me/k00bideh).
|
||||
|
||||
‎
|
||||
##### Where Can I Ask More Questions?
|
||||
Reply to this FAQ. It's like any other post on the site.
|
@ -27,11 +27,13 @@ export const COMMENT_FIELDS = gql`
|
||||
...StreakFields
|
||||
}
|
||||
sats
|
||||
credits
|
||||
meAnonSats @client
|
||||
upvotes
|
||||
freedFreebie
|
||||
boost
|
||||
meSats
|
||||
meCredits
|
||||
meDontLikeSats
|
||||
meBookmark
|
||||
meSubscription
|
||||
@ -39,6 +41,7 @@ export const COMMENT_FIELDS = gql`
|
||||
freebie
|
||||
path
|
||||
commentSats
|
||||
commentCredits
|
||||
mine
|
||||
otsHash
|
||||
ncomments
|
||||
|
@ -38,6 +38,7 @@ export const ITEM_FIELDS = gql`
|
||||
otsHash
|
||||
position
|
||||
sats
|
||||
credits
|
||||
meAnonSats @client
|
||||
boost
|
||||
bounty
|
||||
@ -46,6 +47,7 @@ export const ITEM_FIELDS = gql`
|
||||
path
|
||||
upvotes
|
||||
meSats
|
||||
meCredits
|
||||
meDontLikeSats
|
||||
meBookmark
|
||||
meSubscription
|
||||
@ -55,6 +57,7 @@ export const ITEM_FIELDS = gql`
|
||||
bio
|
||||
ncomments
|
||||
commentSats
|
||||
commentCredits
|
||||
lastCommentAt
|
||||
isJob
|
||||
status
|
||||
|
@ -11,6 +11,7 @@ export const PAID_ACTION = gql`
|
||||
fragment PaidActionFields on PaidAction {
|
||||
invoice {
|
||||
...InvoiceFields
|
||||
invoiceForward
|
||||
}
|
||||
paymentMethod
|
||||
}`
|
||||
@ -117,11 +118,22 @@ export const DONATE = gql`
|
||||
}
|
||||
}`
|
||||
|
||||
export const BUY_CREDITS = gql`
|
||||
${PAID_ACTION}
|
||||
mutation buyCredits($credits: Int!) {
|
||||
buyCredits(credits: $credits) {
|
||||
result {
|
||||
credits
|
||||
}
|
||||
...PaidActionFields
|
||||
}
|
||||
}`
|
||||
|
||||
export const ACT_MUTATION = gql`
|
||||
${PAID_ACTION}
|
||||
${ITEM_ACT_PAID_ACTION_FIELDS}
|
||||
mutation act($id: ID!, $sats: Int!, $act: String) {
|
||||
act(id: $id, sats: $sats, act: $act) {
|
||||
mutation act($id: ID!, $sats: Int!, $act: String, $hasSendWallet: Boolean) {
|
||||
act(id: $id, sats: $sats, act: $act, hasSendWallet: $hasSendWallet) {
|
||||
...ItemActPaidActionFields
|
||||
...PaidActionFields
|
||||
}
|
||||
|
@ -39,6 +39,7 @@ ${STREAK_FIELDS}
|
||||
nostrCrossposting
|
||||
nsfwMode
|
||||
sats
|
||||
credits
|
||||
tipDefault
|
||||
tipRandom
|
||||
tipRandomMin
|
||||
@ -116,6 +117,8 @@ export const SETTINGS_FIELDS = gql`
|
||||
apiKeyEnabled
|
||||
proxyReceive
|
||||
directReceive
|
||||
receiveCreditsBelowSats
|
||||
sendCreditsBelowSats
|
||||
}
|
||||
}`
|
||||
|
||||
|
@ -225,8 +225,8 @@ export const SET_WALLET_PRIORITY = gql`
|
||||
|
||||
export const CANCEL_INVOICE = gql`
|
||||
${INVOICE_FIELDS}
|
||||
mutation cancelInvoice($hash: String!, $hmac: String!) {
|
||||
cancelInvoice(hash: $hash, hmac: $hmac) {
|
||||
mutation cancelInvoice($hash: String!, $hmac: String, $userCancel: Boolean) {
|
||||
cancelInvoice(hash: $hash, hmac: $hmac, userCancel: $userCancel) {
|
||||
...InvoiceFields
|
||||
}
|
||||
}
|
||||
|
18
lib/badge.js
18
lib/badge.js
@ -4,7 +4,8 @@ export const clearNotifications = () => navigator.serviceWorker?.controller?.pos
|
||||
|
||||
const badgingApiSupported = (sw = window) => 'setAppBadge' in sw.navigator
|
||||
|
||||
const permissionGranted = async (sw = window) => {
|
||||
// we don't need this, we can use the badging API
|
||||
/* const permissionGranted = async (sw = window) => {
|
||||
const name = 'notifications'
|
||||
let permission
|
||||
try {
|
||||
@ -13,21 +14,22 @@ const permissionGranted = async (sw = window) => {
|
||||
console.error('Failed to check permissions', err)
|
||||
}
|
||||
return permission?.state === 'granted' || sw.Notification?.permission === 'granted'
|
||||
}
|
||||
} */
|
||||
|
||||
export const setAppBadge = async (sw = window, count) => {
|
||||
if (!badgingApiSupported(sw) || !(await permissionGranted(sw))) return
|
||||
// Apple requirement: onPush doesn't accept async functions
|
||||
export const setAppBadge = (sw = window, count) => {
|
||||
if (!badgingApiSupported(sw)) return
|
||||
try {
|
||||
await sw.navigator.setAppBadge(count)
|
||||
return sw.navigator.setAppBadge(count) // Return a Promise to be handled
|
||||
} catch (err) {
|
||||
console.error('Failed to set app badge', err)
|
||||
}
|
||||
}
|
||||
|
||||
export const clearAppBadge = async (sw = window) => {
|
||||
if (!badgingApiSupported(sw) || !(await permissionGranted(sw))) return
|
||||
export const clearAppBadge = (sw = window) => {
|
||||
if (!badgingApiSupported(sw)) return
|
||||
try {
|
||||
await sw.navigator.clearAppBadge()
|
||||
return sw.navigator.clearAppBadge() // Return a Promise to be handled
|
||||
} catch (err) {
|
||||
console.error('Failed to clear app badge', err)
|
||||
}
|
||||
|
@ -8,7 +8,8 @@ export const PAID_ACTION_PAYMENT_METHODS = {
|
||||
PESSIMISTIC: 'PESSIMISTIC',
|
||||
OPTIMISTIC: 'OPTIMISTIC',
|
||||
DIRECT: 'DIRECT',
|
||||
P2P: 'P2P'
|
||||
P2P: 'P2P',
|
||||
REWARD_SATS: 'REWARD_SATS'
|
||||
}
|
||||
export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']
|
||||
export const NOFOLLOW_LIMIT = 1000
|
||||
@ -50,7 +51,6 @@ export const ITEM_EDIT_SECONDS = 600
|
||||
export const ITEM_SPAM_INTERVAL = '10m'
|
||||
export const ANON_ITEM_SPAM_INTERVAL = '0'
|
||||
export const INV_PENDING_LIMIT = 100
|
||||
export const BALANCE_LIMIT_MSATS = 100000000 // 100k sat
|
||||
export const USER_ID = {
|
||||
k00b: 616,
|
||||
ek: 6030,
|
||||
|
@ -2,11 +2,11 @@ import { string, ValidationError, number, object, array, boolean, date } from '.
|
||||
import {
|
||||
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES,
|
||||
MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES,
|
||||
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX, BALANCE_LIMIT_MSATS
|
||||
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX
|
||||
} from './constants'
|
||||
import { SUPPORTED_CURRENCIES } from './currency'
|
||||
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
|
||||
import { msatsToSats, numWithUnits, abbrNum } from './format'
|
||||
import { numWithUnits } from './format'
|
||||
import { SUB } from '@/fragments/subs'
|
||||
import { NAME_QUERY } from '@/fragments/users'
|
||||
import { datePivot } from './time'
|
||||
@ -185,7 +185,7 @@ export function advSchema (args) {
|
||||
}
|
||||
|
||||
export const autowithdrawSchemaMembers = object({
|
||||
autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`).transform(Number),
|
||||
autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(10000, 'must be at most 10000').transform(Number),
|
||||
autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50').transform(Number),
|
||||
autoWithdrawMaxFeeTotal: intValidator.required('required').min(0, 'must be at least 0').max(1_000, 'must not exceed 1000').transform(Number)
|
||||
})
|
||||
|
123
pages/credits.js
Normal file
123
pages/credits.js
Normal file
@ -0,0 +1,123 @@
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import CCInfo from '@/components/info/cc'
|
||||
import { Form, Input, SubmitButton } from '@/components/form'
|
||||
import { CenterLayout } from '@/components/layout'
|
||||
import { useLightning } from '@/components/lightning'
|
||||
import { useMe } from '@/components/me'
|
||||
import { useShowModal } from '@/components/modal'
|
||||
import { usePaidMutation } from '@/components/use-paid-mutation'
|
||||
import { BUY_CREDITS } from '@/fragments/paidAction'
|
||||
import { amountSchema } from '@/lib/validate'
|
||||
import classNames from 'classnames'
|
||||
import { Button, Col, InputGroup, Row } from 'react-bootstrap'
|
||||
import RewardSatsInfo from '@/components/info/reward-sats'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||
|
||||
export default function Credits () {
|
||||
const { me } = useMe()
|
||||
return (
|
||||
<CenterLayout footer footerLinks>
|
||||
<Row className='w-100 d-none d-sm-flex justify-content-center'>
|
||||
<Col>
|
||||
<h2 className='text-end me-1 me-md-3'>
|
||||
<div className='text-monospace'>
|
||||
{me?.privates?.credits}
|
||||
</div>
|
||||
<div className='text-muted d-flex align-items-baseline justify-content-end'><CCInfo size={16} /> cowboy credits</div>
|
||||
<BuyCreditsButton className='ms-auto' />
|
||||
</h2>
|
||||
</Col>
|
||||
<Col>
|
||||
<h2 className='text-start ms-1 ms-md-3'>
|
||||
<div className='text-monospace'>
|
||||
{me?.privates?.sats - me?.privates?.credits}
|
||||
</div>
|
||||
<div className='text-muted d-flex align-items-baseline justify-content-start'>sats <RewardSatsInfo size={16} /></div>
|
||||
<WithdrawButton className='me-auto' />
|
||||
</h2>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className='w-100 d-flex d-sm-none justify-content-center my-5'>
|
||||
<Row className='mb-5'>
|
||||
<h2 className='text-start'>
|
||||
<div className='text-monospace'>
|
||||
{me?.privates?.credits}
|
||||
</div>
|
||||
<div className='text-muted d-flex align-items-baseline justify-content-start'>cowboy credits <CCInfo size={16} /></div>
|
||||
<BuyCreditsButton className='me-auto' />
|
||||
</h2>
|
||||
</Row>
|
||||
<Row>
|
||||
<h2 className='text-end'>
|
||||
<div className='text-monospace'>
|
||||
{me?.privates?.sats - me?.privates?.credits}
|
||||
</div>
|
||||
<div className='text-muted d-flex align-items-baseline justify-content-end'><RewardSatsInfo size={16} /> sats</div>
|
||||
<WithdrawButton className='ms-auto' />
|
||||
</h2>
|
||||
</Row>
|
||||
</Row>
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function WithdrawButton ({ className }) {
|
||||
return (
|
||||
<Button
|
||||
variant='success'
|
||||
className={classNames('mt-3 d-block', className)}
|
||||
style={{ width: 'fit-content' }}
|
||||
href='/withdraw'
|
||||
>withdraw sats
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function BuyCreditsButton ({ className }) {
|
||||
const showModal = useShowModal()
|
||||
const strike = useLightning()
|
||||
const [buyCredits] = usePaidMutation(BUY_CREDITS)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => showModal(onClose => (
|
||||
<Form
|
||||
initial={{
|
||||
amount: 10000
|
||||
}}
|
||||
schema={amountSchema}
|
||||
onSubmit={async ({ amount }) => {
|
||||
const { error } = await buyCredits({
|
||||
variables: {
|
||||
credits: Number(amount)
|
||||
},
|
||||
onCompleted: () => {
|
||||
strike()
|
||||
}
|
||||
})
|
||||
onClose()
|
||||
if (error) throw error
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='amount'
|
||||
name='amount'
|
||||
type='number'
|
||||
required
|
||||
autoFocus
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<div className='d-flex'>
|
||||
<SubmitButton variant='secondary' className='ms-auto mt-1 px-4'>buy</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
))}
|
||||
className={classNames('mt-3 d-block', className)}
|
||||
variant='secondary'
|
||||
>buy credits
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
@ -64,13 +64,13 @@ function InviteHeader ({ invite }) {
|
||||
if (invite.revoked) {
|
||||
Inner = () => <div className='text-danger'>this invite link expired</div>
|
||||
} else if ((invite.limit && invite.limit <= invite.invitees.length) || invite.poor) {
|
||||
Inner = () => <div className='text-danger'>this invite link has no more sats</div>
|
||||
Inner = () => <div className='text-danger'>this invite link has no more cowboy credits</div>
|
||||
} else {
|
||||
Inner = () => (
|
||||
<div>
|
||||
Get <span className='text-success'>{invite.gift} free sats</span> from{' '}
|
||||
Get <span className='text-success'>{invite.gift} cowboy credits</span> from{' '}
|
||||
<Link href={`/${invite.user.name}`}>@{invite.user.name}</Link>{' '}
|
||||
when you sign up today
|
||||
when you sign up
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -264,7 +264,7 @@ export default function Satistics ({ ssrData }) {
|
||||
<div className={styles.rows}>
|
||||
<div className={[styles.type, styles.head].join(' ')}>type</div>
|
||||
<div className={[styles.detail, styles.head].join(' ')}>detail</div>
|
||||
<div className={[styles.sats, styles.head].join(' ')}>sats</div>
|
||||
<div className={[styles.sats, styles.head].join(' ')}>sats/credits</div>
|
||||
{facts.map(f => <Fact key={f.type + f.id} fact={f} />)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -160,12 +160,15 @@ export default function Settings ({ ssrData }) {
|
||||
hideIsContributor: settings?.hideIsContributor,
|
||||
noReferralLinks: settings?.noReferralLinks,
|
||||
proxyReceive: settings?.proxyReceive,
|
||||
directReceive: settings?.directReceive
|
||||
directReceive: settings?.directReceive,
|
||||
receiveCreditsBelowSats: settings?.receiveCreditsBelowSats,
|
||||
sendCreditsBelowSats: settings?.sendCreditsBelowSats
|
||||
}}
|
||||
schema={settingsSchema}
|
||||
onSubmit={async ({
|
||||
tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault,
|
||||
zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, satsFilter,
|
||||
receiveCreditsBelowSats, sendCreditsBelowSats,
|
||||
...values
|
||||
}) => {
|
||||
if (nostrPubkey.length === 0) {
|
||||
@ -191,6 +194,8 @@ export default function Settings ({ ssrData }) {
|
||||
withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault),
|
||||
satsFilter: Number(satsFilter),
|
||||
zapUndos: zapUndosEnabled ? Number(zapUndos) : null,
|
||||
receiveCreditsBelowSats: Number(receiveCreditsBelowSats),
|
||||
sendCreditsBelowSats: Number(sendCreditsBelowSats),
|
||||
nostrPubkey,
|
||||
nostrRelays: nostrRelaysFiltered,
|
||||
...values
|
||||
@ -335,6 +340,18 @@ export default function Settings ({ ssrData }) {
|
||||
name='noteCowboyHat'
|
||||
/>
|
||||
<div className='form-label'>wallet</div>
|
||||
<Input
|
||||
label='receive credits for zaps and deposits below'
|
||||
name='receiveCreditsBelowSats'
|
||||
required
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<Input
|
||||
label='send credits for zaps below'
|
||||
name='sendCreditsBelowSats'
|
||||
required
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<Checkbox
|
||||
label={
|
||||
<div className='d-flex align-items-center'>proxy deposits to attached wallets
|
||||
|
@ -92,7 +92,7 @@ export default function WalletSettings () {
|
||||
await save(values, values.enabled)
|
||||
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
router.push('/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger(err.message || err.toString?.())
|
||||
@ -115,7 +115,7 @@ export default function WalletSettings () {
|
||||
try {
|
||||
await detach()
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
router.push('/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
const message = 'failed to detach: ' + err.message || err.toString?.()
|
@ -86,10 +86,10 @@ export default function Wallet ({ ssrData }) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className='py-5 w-100'>
|
||||
<h2 className='mb-2 text-center'>attach wallets</h2>
|
||||
<h6 className='text-muted text-center'>attach wallets to supplement your SN wallet</h6>
|
||||
<h2 className='mb-2 text-center'>wallets</h2>
|
||||
<h6 className='text-muted text-center'>use real bitcoin</h6>
|
||||
<div className='text-center'>
|
||||
<Link href='/wallet/logs' className='text-muted fw-bold text-underline'>
|
||||
<Link href='/wallets/logs' className='text-muted fw-bold text-underline'>
|
||||
wallet logs
|
||||
</Link>
|
||||
</div>
|
@ -1,198 +1,68 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { Checkbox, Form, Input, InputUserSuggest, SubmitButton } from '@/components/form'
|
||||
import Link from 'next/link'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
import Qr, { QrSkeleton } from '@/components/qr'
|
||||
import { CenterLayout } from '@/components/layout'
|
||||
import InputGroup from 'react-bootstrap/InputGroup'
|
||||
import { WithdrawlSkeleton } from '@/pages/withdrawals/[id]'
|
||||
import { useMe } from '@/components/me'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { requestProvider } from 'webln'
|
||||
import Alert from 'react-bootstrap/Alert'
|
||||
import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '@/fragments/wallet'
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import { amountSchema, lnAddrSchema, withdrawlSchema } from '@/lib/validate'
|
||||
import Nav from 'react-bootstrap/Nav'
|
||||
import { BALANCE_LIMIT_MSATS, FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||
import { msatsToSats, numWithUnits } from '@/lib/format'
|
||||
import { CenterLayout } from '@/components/layout'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { InputGroup, Nav } from 'react-bootstrap'
|
||||
import styles from '@/components/user-header.module.css'
|
||||
import HiddenWalletSummary from '@/components/hidden-wallet-summary'
|
||||
import AccordianItem from '@/components/accordian-item'
|
||||
import { lnAddrOptions } from '@/lib/lnurl'
|
||||
import useDebounceCallback from '@/components/use-debounce-callback'
|
||||
import { Scanner } from '@yudiel/react-qr-scanner'
|
||||
import CameraIcon from '@/svgs/camera-line.svg'
|
||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '@/fragments/wallet'
|
||||
import { requestProvider } from 'webln'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMe } from '@/components/me'
|
||||
import { WithdrawlSkeleton } from './withdrawals/[id]'
|
||||
import { Checkbox, Form, Input, InputUserSuggest, SubmitButton } from '@/components/form'
|
||||
import { lnAddrSchema, withdrawlSchema } from '@/lib/validate'
|
||||
import { useShowModal } from '@/components/modal'
|
||||
import { useField } from 'formik'
|
||||
import { useToast } from '@/components/toast'
|
||||
import { WalletLimitBanner } from '@/components/banners'
|
||||
import Plug from '@/svgs/plug.svg'
|
||||
import { Scanner } from '@yudiel/react-qr-scanner'
|
||||
import { decode } from 'bolt11'
|
||||
import CameraIcon from '@/svgs/camera-line.svg'
|
||||
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||
import Qr, { QrSkeleton } from '@/components/qr'
|
||||
import useDebounceCallback from '@/components/use-debounce-callback'
|
||||
import { lnAddrOptions } from '@/lib/lnurl'
|
||||
import AccordianItem from '@/components/accordian-item'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||
|
||||
export default function Wallet () {
|
||||
const router = useRouter()
|
||||
|
||||
if (router.query.type === 'fund') {
|
||||
return (
|
||||
<CenterLayout>
|
||||
<FundForm />
|
||||
</CenterLayout>
|
||||
)
|
||||
} else if (router.query.type?.includes('withdraw')) {
|
||||
return (
|
||||
<CenterLayout>
|
||||
<WithdrawalForm />
|
||||
</CenterLayout>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<CenterLayout>
|
||||
<YouHaveSats />
|
||||
<WalletLimitBanner />
|
||||
<WalletForm />
|
||||
<WalletHistory />
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
export default function Withdraw () {
|
||||
return (
|
||||
<CenterLayout>
|
||||
<WithdrawForm />
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function YouHaveSats () {
|
||||
function WithdrawForm () {
|
||||
const router = useRouter()
|
||||
const { me } = useMe()
|
||||
const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
|
||||
return (
|
||||
<h2 className={`${me ? 'visible' : 'invisible'} ${limitReached ? 'text-warning' : 'text-success'}`}>
|
||||
you have{' '}
|
||||
<span className='text-monospace'>{me && (
|
||||
me.privates?.hideWalletBalance
|
||||
? <HiddenWalletSummary />
|
||||
: numWithUnits(me.privates?.sats, { abbreviate: false, format: true })
|
||||
)}
|
||||
</span>
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletHistory () {
|
||||
return (
|
||||
<div className='d-flex flex-column text-center'>
|
||||
<div>
|
||||
<Link href='/satistics?inc=invoice,withdrawal' className='text-muted fw-bold text-underline'>
|
||||
wallet history
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WalletForm () {
|
||||
return (
|
||||
<div className='align-items-center text-center pt-5 pb-4'>
|
||||
<Link href='/wallet?type=fund'>
|
||||
<Button variant='success'>fund</Button>
|
||||
</Link>
|
||||
<span className='mx-3 fw-bold text-muted'>or</span>
|
||||
<Link href='/wallet?type=withdraw'>
|
||||
<Button variant='success'>withdraw</Button>
|
||||
</Link>
|
||||
<div className='mt-5'>
|
||||
<Link href='/settings/wallets'>
|
||||
<Button variant='info'>attach wallets <Plug className='fill-white ms-1' width={16} height={16} /></Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FundForm () {
|
||||
const { me } = useMe()
|
||||
const [showAlert, setShowAlert] = useState(true)
|
||||
const router = useRouter()
|
||||
const [createInvoice, { called, error }] = useMutation(gql`
|
||||
mutation createInvoice($amount: Int!) {
|
||||
createInvoice(amount: $amount) {
|
||||
__typename
|
||||
id
|
||||
}
|
||||
}`)
|
||||
|
||||
useEffect(() => {
|
||||
setShowAlert(!window.localStorage.getItem('hideLnAddrAlert'))
|
||||
}, [])
|
||||
|
||||
if (called && !error) {
|
||||
return <QrSkeleton description status='generating' bolt11Info />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<YouHaveSats />
|
||||
<WalletLimitBanner />
|
||||
<div className='w-100 py-5'>
|
||||
{me && showAlert &&
|
||||
<Alert
|
||||
variant='success' dismissible onClose={() => {
|
||||
window.localStorage.setItem('hideLnAddrAlert', 'yep')
|
||||
setShowAlert(false)
|
||||
}}
|
||||
>
|
||||
You can also fund your account via lightning address with <strong>{`${me.name}@stacker.news`}</strong>
|
||||
</Alert>}
|
||||
<Form
|
||||
initial={{
|
||||
amount: 1000
|
||||
}}
|
||||
schema={amountSchema}
|
||||
onSubmit={async ({ amount }) => {
|
||||
const { data } = await createInvoice({ variables: { amount: Number(amount) } })
|
||||
if (data.createInvoice.__typename === 'Direct') {
|
||||
router.push(`/directs/${data.createInvoice.id}`)
|
||||
} else {
|
||||
router.push(`/invoices/${data.createInvoice.id}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='amount'
|
||||
name='amount'
|
||||
required
|
||||
autoFocus
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<SubmitButton variant='success' className='mt-2'>generate invoice</SubmitButton>
|
||||
</Form>
|
||||
</div>
|
||||
<WalletHistory />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function WithdrawalForm () {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='w-100 d-flex flex-column align-items-center py-5'>
|
||||
<YouHaveSats />
|
||||
<h2 className='text-start ms-1 ms-md-3'>
|
||||
<div className='text-monospace'>
|
||||
{numWithUnits(me?.privates?.sats - me?.privates?.credits, { abbreviate: false, format: true, unitSingular: 'sats', unitPlural: 'sats' })}
|
||||
</div>
|
||||
</h2>
|
||||
<Nav
|
||||
className={styles.nav}
|
||||
activeKey={router.query.type}
|
||||
activeKey={router.query.type ?? 'invoice'}
|
||||
>
|
||||
<Nav.Item>
|
||||
<Link href='/wallet?type=withdraw' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='withdraw'>invoice</Nav.Link>
|
||||
<Link href='/withdraw' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='invoice'>invoice</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href='/wallet?type=lnurl-withdraw' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='lnurl-withdraw'>QR code</Nav.Link>
|
||||
<Link href='/withdraw?type=lnurl' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='lnurl'>QR code</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href='/wallet?type=lnaddr-withdraw' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='lnaddr-withdraw'>lightning address</Nav.Link>
|
||||
<Link href='/withdraw?type=lnaddr' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='lnaddr'>lightning address</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
@ -205,12 +75,12 @@ export function SelectedWithdrawalForm () {
|
||||
const router = useRouter()
|
||||
|
||||
switch (router.query.type) {
|
||||
case 'withdraw':
|
||||
return <InvWithdrawal />
|
||||
case 'lnurl-withdraw':
|
||||
return <LnWithdrawal />
|
||||
case 'lnaddr-withdraw':
|
||||
case 'lnurl':
|
||||
return <LnurlWithdrawal />
|
||||
case 'lnaddr':
|
||||
return <LnAddrWithdrawal />
|
||||
default:
|
||||
return <InvWithdrawal />
|
||||
}
|
||||
}
|
||||
|
||||
@ -271,7 +141,9 @@ export function InvWithdrawal () {
|
||||
required
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<SubmitButton variant='success' className='mt-2'>withdraw</SubmitButton>
|
||||
<div className='d-flex justify-content-end mt-4'>
|
||||
<SubmitButton variant='success'>withdraw</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
@ -343,7 +215,7 @@ function LnQRWith ({ k1, encodedUrl }) {
|
||||
return <Qr value={encodedUrl} status='waiting for you' />
|
||||
}
|
||||
|
||||
export function LnWithdrawal () {
|
||||
export function LnurlWithdrawal () {
|
||||
// query for challenge
|
||||
const [createWith, { data, error }] = useMutation(gql`
|
||||
mutation createWith {
|
||||
@ -507,7 +379,9 @@ export function LnAddrWithdrawal () {
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
<SubmitButton variant='success' className='mt-2'>send</SubmitButton>
|
||||
<div className='d-flex justify-content-end mt-4'>
|
||||
<SubmitButton variant='success'>send</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)
|
78
prisma/migrations/20241203195142_fee_credits/migration.sql
Normal file
78
prisma/migrations/20241203195142_fee_credits/migration.sql
Normal file
@ -0,0 +1,78 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Item" ADD COLUMN "mcredits" BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "commentMcredits" BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "mcredits" BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "stackedMcredits" BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "receiveCreditsBelowSats" INTEGER NOT NULL DEFAULT 10,
|
||||
ADD COLUMN "sendCreditsBelowSats" INTEGER NOT NULL DEFAULT 10;
|
||||
|
||||
-- default to true now
|
||||
ALTER TABLE "users" ALTER COLUMN "proxyReceive" SET DEFAULT true,
|
||||
ALTER COLUMN "directReceive" SET DEFAULT true;
|
||||
|
||||
-- if they don't have either set, set to true
|
||||
UPDATE "users" SET "proxyReceive" = true, "directReceive" = true
|
||||
WHERE NOT "proxyReceive" AND NOT "directReceive";
|
||||
|
||||
-- add mcredits check
|
||||
ALTER TABLE users ADD CONSTRAINT "mcredits_positive" CHECK ("mcredits" >= 0) NOT VALID;
|
||||
ALTER TABLE users ADD CONSTRAINT "stackedMcredits_positive" CHECK ("stackedMcredits" >= 0) NOT VALID;
|
||||
|
||||
-- add cowboy credits
|
||||
CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me(_item_id int, _global_seed int, _me_id int, _level int, _where text, _order_by text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
|
||||
$$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
BEGIN
|
||||
IF _level < 1 THEN
|
||||
RETURN '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS'
|
||||
|| ' SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|
||||
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", to_jsonb(users.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
|
||||
|| ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
|
||||
|| ' COALESCE("ItemAct"."meMcredits", 0) AS "meMcredits", COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", '
|
||||
|| ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", '
|
||||
|| ' GREATEST(g.tf_hot_score, l.tf_hot_score) AS personal_hot_score, GREATEST(g.tf_top_score, l.tf_top_score) AS personal_top_score '
|
||||
|| ' FROM "Item" '
|
||||
|| ' JOIN users ON users.id = "Item"."userId" '
|
||||
|| ' LEFT JOIN "Mute" ON "Mute"."muterId" = $5 AND "Mute"."mutedId" = "Item"."userId"'
|
||||
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $5 AND "Bookmark"."itemId" = "Item".id '
|
||||
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $5 AND "ThreadSubscription"."itemId" = "Item".id '
|
||||
|| ' LEFT JOIN LATERAL ( '
|
||||
|| ' SELECT "itemId", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMsats", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMcredits", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMsats", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMcredits", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
|
||||
|| ' FROM "ItemAct" '
|
||||
|| ' LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" '
|
||||
|| ' LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" '
|
||||
|| ' WHERE "ItemAct"."userId" = $5 '
|
||||
|| ' AND "ItemAct"."itemId" = "Item".id '
|
||||
|| ' GROUP BY "ItemAct"."itemId" '
|
||||
|| ' ) "ItemAct" ON true '
|
||||
|| ' LEFT JOIN zap_rank_personal_view g ON g."viewerId" = $6 AND g.id = "Item".id '
|
||||
|| ' LEFT JOIN zap_rank_personal_view l ON l."viewerId" = $5 AND l.id = g.id '
|
||||
|| ' WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) ' || _where || ' '
|
||||
USING _item_id, _level, _where, _order_by, _me_id, _global_seed;
|
||||
|
||||
EXECUTE ''
|
||||
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|
||||
|| 'FROM ( '
|
||||
|| ' SELECT "Item".*, item_comments_zaprank_with_me("Item".id, $6, $5, $2 - 1, $3, $4) AS comments '
|
||||
|| ' FROM t_item "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by
|
||||
|| ' ) sub'
|
||||
INTO result USING _item_id, _level, _where, _order_by, _me_id, _global_seed;
|
||||
|
||||
RETURN result;
|
||||
END
|
||||
$$;
|
@ -0,0 +1,28 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Invoice" ADD COLUMN "userCancel" BOOLEAN;
|
||||
|
||||
-- Migrate existing rows
|
||||
UPDATE "Invoice" SET "userCancel" = false;
|
||||
|
||||
-- Add constraint to ensure consistent cancel state
|
||||
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_cancel" CHECK (
|
||||
("cancelled" = true AND "cancelledAt" IS NOT NULL AND "userCancel" IS NOT NULL) OR
|
||||
("cancelled" = false AND "cancelledAt" IS NULL AND "userCancel" IS NULL)
|
||||
);
|
||||
|
||||
-- Add trigger to set userCancel to false by default when cancelled updated and userCancel not specified
|
||||
CREATE OR REPLACE FUNCTION invoice_set_user_cancel_default()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.cancelled AND NEW."userCancel" IS NULL THEN
|
||||
NEW."userCancel" := false;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER invoice_user_cancel_trigger
|
||||
BEFORE UPDATE ON "Invoice"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION invoice_set_user_cancel_default();
|
||||
|
@ -0,0 +1,6 @@
|
||||
DROP TRIGGER IF EXISTS user_streak ON users;
|
||||
CREATE TRIGGER user_streak
|
||||
AFTER UPDATE ON users
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.msats < OLD.msats OR NEW.mcredits < OLD.mcredits)
|
||||
EXECUTE PROCEDURE user_streak_check();
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Invite" ALTER COLUMN "id" SET DEFAULT encode(gen_random_bytes(16), 'hex'::text);
|
@ -0,0 +1,37 @@
|
||||
CREATE OR REPLACE FUNCTION update_weekly_posts_job()
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
UPDATE pgboss.schedule
|
||||
SET data = jsonb_build_object(
|
||||
'title', 'Meme Monday - Best Bitcoin Meme Gets 5,000 CCs',
|
||||
'text', E'Time for another round of Meme Monday!\n\nWe have another 5,000 CCs up for grabs for this week''s winner.\n\nThe CCs will be given to the stacker with the best Bitcoin meme as voted by the "top" filter on this thread at 10am CT tomorrow.\n\nTo post an image on SN, check out our docs [here](https://stacker.news/faq#how-do-i-post-images-on-stacker-news).\n\nSend your best 👇',
|
||||
'bounty', 5000,
|
||||
'subName', 'memes')
|
||||
WHERE name = 'weeklyPost-meme-mon';
|
||||
|
||||
UPDATE pgboss.schedule
|
||||
SET data = jsonb_build_object(
|
||||
'title', 'What are you working on this week?',
|
||||
'text', E'Calling all stackers!\n\nLeave a comment below to let the SN community know what you''re working on this week. It doesn''t matter how big or small your project is, or how much progress you''ve made.\n\nJust share what you''re up to, and let the community know if you want any feedback or help.',
|
||||
'subName', 'meta')
|
||||
WHERE name = 'weeklyPost-what-wed';
|
||||
|
||||
UPDATE pgboss.schedule
|
||||
SET data = jsonb_build_object(
|
||||
'title', 'Fun Fact Friday - Best Fun Fact Gets 5,000 CCs',
|
||||
'text', E'Let''s hear all your best fun facts, any topic counts!\n\nThe best comment as voted by the "top" filter at 10am CT tomorrow gets 5,000 CCs.\n\nBonus CCs for including a source link to your fun fact!\n\nSend your best 👇',
|
||||
'bounty', 5000,
|
||||
'subName', 'meta')
|
||||
WHERE name = 'weeklyPost-fact-fri';
|
||||
|
||||
return 0;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
return 0;
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT update_weekly_posts_job();
|
||||
DROP FUNCTION IF EXISTS update_weekly_posts_job;
|
@ -0,0 +1,17 @@
|
||||
CREATE OR REPLACE FUNCTION schedule_daily_rewards_refill_job()
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
-- 10 minutes after midnight
|
||||
INSERT INTO pgboss.schedule (name, cron, timezone)
|
||||
VALUES ('earnRefill', '10 0 * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
|
||||
return 0;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
return 0;
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT schedule_daily_rewards_refill_job();
|
||||
DROP FUNCTION IF EXISTS schedule_daily_rewards_refill_job;
|
@ -39,6 +39,7 @@ model User {
|
||||
trust Float @default(0)
|
||||
lastSeenAt DateTime?
|
||||
stackedMsats BigInt @default(0)
|
||||
stackedMcredits BigInt @default(0)
|
||||
noteAllDescendants Boolean @default(true)
|
||||
noteDeposits Boolean @default(true)
|
||||
noteWithdrawals Boolean @default(true)
|
||||
@ -119,6 +120,9 @@ model User {
|
||||
autoWithdrawMaxFeePercent Float?
|
||||
autoWithdrawThreshold Int?
|
||||
autoWithdrawMaxFeeTotal Int?
|
||||
mcredits BigInt @default(0)
|
||||
receiveCreditsBelowSats Int @default(10)
|
||||
sendCreditsBelowSats Int @default(10)
|
||||
muters Mute[] @relation("muter")
|
||||
muteds Mute[] @relation("muted")
|
||||
ArcOut Arc[] @relation("fromUser")
|
||||
@ -140,8 +144,8 @@ model User {
|
||||
vaultKeyHash String @default("")
|
||||
walletsUpdatedAt DateTime?
|
||||
vaultEntries VaultEntry[] @relation("VaultEntries")
|
||||
proxyReceive Boolean @default(false)
|
||||
directReceive Boolean @default(false)
|
||||
proxyReceive Boolean @default(true)
|
||||
directReceive Boolean @default(true)
|
||||
DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
|
||||
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
|
||||
|
||||
@ -467,7 +471,7 @@ model LnWith {
|
||||
}
|
||||
|
||||
model Invite {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(dbgenerated("encode(gen_random_bytes(16), 'hex'::text)"))
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
userId Int
|
||||
@ -520,10 +524,12 @@ model Item {
|
||||
pollCost Int?
|
||||
paidImgLink Boolean @default(false)
|
||||
commentMsats BigInt @default(0)
|
||||
commentMcredits BigInt @default(0)
|
||||
lastCommentAt DateTime?
|
||||
lastZapAt DateTime?
|
||||
ncomments Int @default(0)
|
||||
msats BigInt @default(0)
|
||||
mcredits BigInt @default(0)
|
||||
cost Int @default(0)
|
||||
weightedDownVotes Float @default(0)
|
||||
bio Boolean @default(false)
|
||||
@ -917,6 +923,7 @@ model Invoice {
|
||||
confirmedIndex BigInt?
|
||||
cancelled Boolean @default(false)
|
||||
cancelledAt DateTime?
|
||||
userCancel Boolean?
|
||||
msatsRequested BigInt
|
||||
msatsReceived BigInt?
|
||||
desc String?
|
||||
|
81
scripts/deploy_user_documentation.js
Normal file
81
scripts/deploy_user_documentation.js
Normal file
@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const SN_API_URL = process.env.SN_API_URL ?? 'http://localhost:3000'
|
||||
const SN_API_KEY = process.env.SN_API_KEY
|
||||
|
||||
const parseFrontMatter = (content) => {
|
||||
const lines = content.split('\n')
|
||||
if (lines[0] !== '---') {
|
||||
throw new Error('failed to parse front matter: start delimiter not found')
|
||||
}
|
||||
|
||||
const endIndex = lines.findIndex((line, i) => i > 0 && line === '---')
|
||||
if (endIndex === -1) {
|
||||
throw new Error('failed to parse front matter: end delimiter not found')
|
||||
}
|
||||
|
||||
const meta = {}
|
||||
for (let i = 1; i < endIndex; i++) {
|
||||
const line = lines[i]
|
||||
const [key, ...valueParts] = line.split(':')
|
||||
if (key && valueParts.length) {
|
||||
meta[key.trim()] = valueParts.join(':').trim()
|
||||
}
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
const readItem = (name) => {
|
||||
const content = fs.readFileSync(path.join(__dirname, name), 'utf8')
|
||||
const lines = content.split('\n')
|
||||
const startIndex = lines.findIndex((line, i) => i > 0 && line.startsWith('---')) + 1
|
||||
return {
|
||||
...parseFrontMatter(content),
|
||||
text: lines.slice(startIndex).join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertDiscussion (variables) {
|
||||
if (!SN_API_KEY) {
|
||||
throw new Error('SN_API_KEY is not set')
|
||||
}
|
||||
|
||||
const response = await fetch(`${SN_API_URL}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': SN_API_KEY
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
mutation upsertDiscussion($id: ID!, $sub: String!, $title: String!, $text: String!) {
|
||||
upsertDiscussion(id: $id, sub: $sub, title: $title, text: $text) {
|
||||
result {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables
|
||||
})
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`failed to upsert discussion: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
if (json.errors) {
|
||||
throw new Error(json.errors[0].message)
|
||||
}
|
||||
|
||||
return json.data
|
||||
}
|
||||
|
||||
const faq = readItem('../docs/user/faq.md')
|
||||
|
||||
upsertDiscussion(faq)
|
@ -2,8 +2,9 @@ import ServiceWorkerStorage from 'serviceworker-storage'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
import { CLEAR_NOTIFICATIONS, clearAppBadge, setAppBadge } from '@/lib/badge'
|
||||
import { ACTION_PORT, DELETE_SUBSCRIPTION, MESSAGE_PORT, STORE_OS, STORE_SUBSCRIPTION, SYNC_SUBSCRIPTION } from '@/components/serviceworker'
|
||||
// import { getLogger } from '@/lib/logger'
|
||||
|
||||
// we store existing push subscriptions to keep them in sync with server
|
||||
// we store existing push subscriptions and OS to keep them in sync with server
|
||||
const storage = new ServiceWorkerStorage('sw:storage', 1)
|
||||
|
||||
// for communication between app and service worker
|
||||
@ -13,103 +14,79 @@ let actionChannelPort
|
||||
|
||||
// operating system. the value will be received via a STORE_OS message from app since service workers don't have access to window.navigator
|
||||
let os = ''
|
||||
const iOS = () => os === 'iOS'
|
||||
async function getOS () {
|
||||
if (!os) {
|
||||
os = await storage.getItem('os') || ''
|
||||
}
|
||||
return os
|
||||
}
|
||||
|
||||
// current push notification count for badge purposes
|
||||
let activeCount = 0
|
||||
|
||||
// message event listener for communication between app and service worker
|
||||
const log = (message, level = 'info', context) => {
|
||||
messageChannelPort?.postMessage({ level, message, context })
|
||||
}
|
||||
|
||||
export function onPush (sw) {
|
||||
return async (event) => {
|
||||
const payload = event.data?.json()
|
||||
if (!payload) return
|
||||
return (event) => {
|
||||
// in case of push notifications, make sure that the logger has an HTTPS endpoint
|
||||
// const logger = getLogger('sw:push', ['onPush'])
|
||||
let payload = event.data?.json()
|
||||
if (!payload) return // ignore push events without payload, like isTrusted events
|
||||
const { tag } = payload.options
|
||||
event.waitUntil((async () => {
|
||||
// generate random ID for every incoming push for better tracing in logs
|
||||
const nid = crypto.randomUUID()
|
||||
log(`[sw:push] ${nid} - received notification with tag ${tag}`)
|
||||
const nid = crypto.randomUUID() // notification id for tracking
|
||||
|
||||
// due to missing proper tag support in Safari on iOS, we can't rely on the tag built-in filter.
|
||||
// we therefore fetch all notifications with the same tag and manually filter them, too.
|
||||
// see https://bugs.webkit.org/show_bug.cgi?id=258922
|
||||
const notifications = await sw.registration.getNotifications({ tag })
|
||||
log(`[sw:push] ${nid} - found ${notifications.length} ${tag} notifications`)
|
||||
log(`[sw:push] ${nid} - built-in tag filter: ${JSON.stringify(notifications.map(({ tag }) => tag))}`)
|
||||
// iOS requirement: group all promises
|
||||
const promises = []
|
||||
|
||||
// we're not sure if the built-in tag filter actually filters by tag on iOS
|
||||
// or if it just returns all currently displayed notifications (?)
|
||||
const filtered = notifications.filter(({ tag: nTag }) => nTag === tag)
|
||||
log(`[sw:push] ${nid} - found ${filtered.length} ${tag} notifications after manual tag filter`)
|
||||
log(`[sw:push] ${nid} - manual tag filter: ${JSON.stringify(filtered.map(({ tag }) => tag))}`)
|
||||
|
||||
if (immediatelyShowNotification(tag)) {
|
||||
// we can't rely on the tag property to replace notifications on Safari on iOS.
|
||||
// we therefore close them manually and then we display the notification.
|
||||
log(`[sw:push] ${nid} - ${tag} notifications replace previous notifications`)
|
||||
setAppBadge(sw, ++activeCount)
|
||||
// due to missing proper tag support in Safari on iOS, we can't rely on the tag property to replace notifications.
|
||||
// see https://bugs.webkit.org/show_bug.cgi?id=258922 for more information
|
||||
// we therefore fetch all notifications with the same tag (+ manual filter),
|
||||
// close them and then we display the notification.
|
||||
const notifications = await sw.registration.getNotifications({ tag })
|
||||
// we only close notifications manually on iOS because we don't want to degrade android UX just because iOS is behind in their support.
|
||||
if (iOS()) {
|
||||
log(`[sw:push] ${nid} - closing existing notifications`)
|
||||
notifications.filter(({ tag: nTag }) => nTag === tag).forEach(n => n.close())
|
||||
// On immediate notifications we update the counter
|
||||
if (immediatelyShowNotification(tag)) {
|
||||
// logger.info(`[${nid}] showing immediate notification with title: ${payload.title}`)
|
||||
promises.push(setAppBadge(sw, ++activeCount))
|
||||
} else {
|
||||
// logger.info(`[${nid}] checking for existing notification with tag ${tag}`)
|
||||
// Check if there are already notifications with the same tag and merge them
|
||||
promises.push(sw.registration.getNotifications({ tag }).then((notifications) => {
|
||||
// logger.info(`[${nid}] found ${notifications.length} notifications with tag ${tag}`)
|
||||
if (notifications.length) {
|
||||
// logger.info(`[${nid}] found ${notifications.length} notifications with tag ${tag}`)
|
||||
payload = mergeNotification(event, sw, payload, notifications, tag, nid)
|
||||
}
|
||||
log(`[sw:push] ${nid} - show notification with title "${payload.title}"`)
|
||||
return await sw.registration.showNotification(payload.title, payload.options)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// according to the spec, there should only be zero or one notification since we used a tag filter
|
||||
// handle zero case here
|
||||
if (notifications.length === 0) {
|
||||
// incoming notification is first notification with this tag
|
||||
log(`[sw:push] ${nid} - no existing ${tag} notifications found`)
|
||||
setAppBadge(sw, ++activeCount)
|
||||
log(`[sw:push] ${nid} - show notification with title "${payload.title}"`)
|
||||
return await sw.registration.showNotification(payload.title, payload.options)
|
||||
}
|
||||
|
||||
// handle unexpected case here
|
||||
if (notifications.length > 1) {
|
||||
log(`[sw:push] ${nid} - more than one notification with tag ${tag} found`, 'error')
|
||||
// due to missing proper tag support in Safari on iOS,
|
||||
// we only acknowledge this error in our logs and don't bail here anymore
|
||||
// see https://bugs.webkit.org/show_bug.cgi?id=258922 for more information
|
||||
log(`[sw:push] ${nid} - skip bail -- merging notifications with tag ${tag} manually`)
|
||||
// return null
|
||||
}
|
||||
|
||||
return await mergeAndShowNotification(sw, payload, notifications, tag, nid)
|
||||
})())
|
||||
// iOS requirement: wait for all promises to resolve before showing the notification
|
||||
event.waitUntil(Promise.all(promises).then(() => {
|
||||
sw.registration.showNotification(payload.title, payload.options)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// if there is no tag or it's a TIP, FORWARDEDTIP or EARN notification
|
||||
// we don't need to merge notifications and thus the notification should be immediately shown using `showNotification`
|
||||
// if there is no tag or the tag is one of the following
|
||||
// we show the notification immediately
|
||||
const immediatelyShowNotification = (tag) =>
|
||||
!tag || ['TIP', 'FORWARDEDTIP', 'EARN', 'STREAK', 'TERRITORY_TRANSFER'].includes(tag.split('-')[0])
|
||||
|
||||
const mergeAndShowNotification = async (sw, payload, currentNotifications, tag, nid) => {
|
||||
// merge notifications with the same tag
|
||||
const mergeNotification = (event, sw, payload, currentNotifications, tag, nid) => {
|
||||
// const logger = getLogger('sw:push:mergeNotification', ['mergeNotification'])
|
||||
|
||||
// sanity check
|
||||
const otherTagNotifications = currentNotifications.filter(({ tag: nTag }) => nTag !== tag)
|
||||
if (otherTagNotifications.length > 0) {
|
||||
// we can't recover from this here. bail.
|
||||
const message = `[sw:push] ${nid} - bailing -- more than one notification with tag ${tag} found after manual filter`
|
||||
log(message, 'error')
|
||||
// logger.error(`${nid} - bailing -- more than one notification with tag ${tag} found after manual filter`)
|
||||
return
|
||||
}
|
||||
|
||||
const { data: incomingData } = payload.options
|
||||
log(`[sw:push] ${nid} - incoming payload.options.data: ${JSON.stringify(incomingData)}`)
|
||||
// logger.info(`[sw:push] ${nid} - incoming payload.options.data: ${JSON.stringify(incomingData)}`)
|
||||
|
||||
// we can ignore everything after the first dash in the tag for our control flow
|
||||
const compareTag = tag.split('-')[0]
|
||||
log(`[sw:push] ${nid} - using ${compareTag} for control flow`)
|
||||
// logger.info(`[sw:push] ${nid} - using ${compareTag} for control flow`)
|
||||
|
||||
// merge notifications into single notification payload
|
||||
// ---
|
||||
@ -118,22 +95,20 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag,
|
||||
// tags that need to know the sum of sats of notifications with same tag for merging
|
||||
const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL']
|
||||
// this should reflect the amount of notifications that were already merged before
|
||||
let initialAmount = currentNotifications[0]?.data?.amount || 1
|
||||
if (iOS()) initialAmount = 1
|
||||
log(`[sw:push] ${nid} - initial amount: ${initialAmount}`)
|
||||
const mergedPayload = currentNotifications.reduce((acc, { data }) => {
|
||||
let newAmount, newSats
|
||||
if (AMOUNT_TAGS.includes(compareTag)) {
|
||||
newAmount = acc.amount + 1
|
||||
}
|
||||
if (SUM_SATS_TAGS.includes(compareTag)) {
|
||||
newSats = acc.sats + data.sats
|
||||
}
|
||||
const newPayload = { ...data, amount: newAmount, sats: newSats }
|
||||
return newPayload
|
||||
}, { ...incomingData, amount: initialAmount })
|
||||
const initialAmount = currentNotifications.length || 1
|
||||
const initialSats = currentNotifications[0]?.data?.sats || 0
|
||||
// logger.info(`[sw:push] ${nid} - initial amount: ${initialAmount}`)
|
||||
// logger.info(`[sw:push] ${nid} - initial sats: ${initialSats}`)
|
||||
|
||||
log(`[sw:push] ${nid} - merged payload: ${JSON.stringify(mergedPayload)}`)
|
||||
// currentNotifications.reduce causes iOS to sum n notifications + initialAmount which is already n notifications
|
||||
const mergedPayload = {
|
||||
...incomingData,
|
||||
url: '/notifications', // when merged we should always go to the notifications page
|
||||
amount: initialAmount + 1,
|
||||
sats: initialSats + incomingData.sats
|
||||
}
|
||||
|
||||
// logger.info(`[sw:push] ${nid} - merged payload: ${JSON.stringify(mergedPayload)}`)
|
||||
|
||||
// calculate title from merged payload
|
||||
const { amount, followeeName, subName, subType, sats } = mergedPayload
|
||||
@ -161,32 +136,30 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag,
|
||||
title = `${numWithUnits(sats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`
|
||||
}
|
||||
}
|
||||
log(`[sw:push] ${nid} - calculated title: ${title}`)
|
||||
// logger.info(`[sw:push] ${nid} - calculated title: ${title}`)
|
||||
|
||||
// close all current notifications before showing new one to "merge" notifications
|
||||
// we only do this on iOS because we don't want to degrade android UX just because iOS is behind in their support.
|
||||
if (iOS()) {
|
||||
log(`[sw:push] ${nid} - closing existing notifications`)
|
||||
currentNotifications.forEach(n => n.close())
|
||||
}
|
||||
|
||||
const options = { icon: payload.options?.icon, tag, data: { url: '/notifications', ...mergedPayload } }
|
||||
log(`[sw:push] ${nid} - show notification with title "${title}"`)
|
||||
return await sw.registration.showNotification(title, options)
|
||||
const options = { icon: payload.options?.icon, tag, data: { ...mergedPayload } }
|
||||
// logger.info(`[sw:push] ${nid} - show notification with title "${title}"`)
|
||||
return { title, options } // send the new, merged, payload
|
||||
}
|
||||
|
||||
// iOS-specific bug, notificationclick event only works when the app is closed
|
||||
export function onNotificationClick (sw) {
|
||||
return (event) => {
|
||||
const promises = []
|
||||
// const logger = getLogger('sw:onNotificationClick', ['onNotificationClick'])
|
||||
const url = event.notification.data?.url
|
||||
// logger.info(`[sw:onNotificationClick] clicked notification with url ${url}`)
|
||||
if (url) {
|
||||
event.waitUntil(sw.clients.openWindow(url))
|
||||
promises.push(sw.clients.openWindow(url))
|
||||
}
|
||||
activeCount = Math.max(0, activeCount - 1)
|
||||
if (activeCount === 0) {
|
||||
clearAppBadge(sw)
|
||||
promises.push(clearAppBadge(sw))
|
||||
} else {
|
||||
setAppBadge(sw, activeCount)
|
||||
promises.push(setAppBadge(sw, activeCount))
|
||||
}
|
||||
event.waitUntil(Promise.all(promises))
|
||||
event.notification.close()
|
||||
}
|
||||
}
|
||||
@ -196,10 +169,11 @@ export function onPushSubscriptionChange (sw) {
|
||||
// `isSync` is passed if function was called because of 'SYNC_SUBSCRIPTION' event
|
||||
// this makes sure we can differentiate between 'pushsubscriptionchange' events and our custom 'SYNC_SUBSCRIPTION' event
|
||||
return async (event, isSync) => {
|
||||
// const logger = getLogger('sw:onPushSubscriptionChange', ['onPushSubscriptionChange'])
|
||||
let { oldSubscription, newSubscription } = event
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event
|
||||
// fallbacks since browser may not set oldSubscription and newSubscription
|
||||
log('[sw:handlePushSubscriptionChange] invoked')
|
||||
// logger.info('[sw:handlePushSubscriptionChange] invoked')
|
||||
oldSubscription ??= await storage.getItem('subscription')
|
||||
newSubscription ??= await sw.registration.pushManager.getSubscription()
|
||||
if (!newSubscription) {
|
||||
@ -208,17 +182,17 @@ export function onPushSubscriptionChange (sw) {
|
||||
// see https://github.com/stackernews/stacker.news/issues/411#issuecomment-1790675861
|
||||
// NOTE: this is only run on IndexedDB subscriptions stored under service worker version 2 since this is not backwards compatible
|
||||
// see discussion in https://github.com/stackernews/stacker.news/pull/597
|
||||
log('[sw:handlePushSubscriptionChange] service worker lost subscription')
|
||||
// logger.info('[sw:handlePushSubscriptionChange] service worker lost subscription')
|
||||
actionChannelPort?.postMessage({ action: 'RESUBSCRIBE' })
|
||||
return
|
||||
}
|
||||
// no subscription exists at the moment
|
||||
log('[sw:handlePushSubscriptionChange] no existing subscription found')
|
||||
// logger.info('[sw:handlePushSubscriptionChange] no existing subscription found')
|
||||
return
|
||||
}
|
||||
if (oldSubscription?.endpoint === newSubscription.endpoint) {
|
||||
// subscription did not change. no need to sync with server
|
||||
log('[sw:handlePushSubscriptionChange] old subscription matches existing subscription')
|
||||
// logger.info('[sw:handlePushSubscriptionChange] old subscription matches existing subscription')
|
||||
return
|
||||
}
|
||||
// convert keys from ArrayBuffer to string
|
||||
@ -243,25 +217,27 @@ export function onPushSubscriptionChange (sw) {
|
||||
},
|
||||
body
|
||||
})
|
||||
log('[sw:handlePushSubscriptionChange] synced push subscription with server', 'info', { endpoint: variables.endpoint, oldEndpoint: variables.oldEndpoint })
|
||||
// logger.info('[sw:handlePushSubscriptionChange] synced push subscription with server', 'info', { endpoint: variables.endpoint, oldEndpoint: variables.oldEndpoint })
|
||||
await storage.setItem('subscription', JSON.parse(JSON.stringify(newSubscription)))
|
||||
}
|
||||
}
|
||||
|
||||
export function onMessage (sw) {
|
||||
return (event) => {
|
||||
return async (event) => {
|
||||
if (event.data.action === ACTION_PORT) {
|
||||
actionChannelPort = event.ports[0]
|
||||
return
|
||||
}
|
||||
if (event.data.action === STORE_OS) {
|
||||
os = event.data.os
|
||||
event.waitUntil(storage.setItem('os', event.data.os))
|
||||
return
|
||||
}
|
||||
if (event.data.action === MESSAGE_PORT) {
|
||||
messageChannelPort = event.ports[0]
|
||||
}
|
||||
log('[sw:message] received message', 'info', { action: event.data.action })
|
||||
const currentOS = event.waitUntil(getOS())
|
||||
log('[sw:message] stored os: ' + currentOS, 'info', { action: event.data.action })
|
||||
if (event.data.action === STORE_SUBSCRIPTION) {
|
||||
log('[sw:message] storing subscription in IndexedDB', 'info', { endpoint: event.data.subscription.endpoint })
|
||||
return event.waitUntil(storage.setItem('subscription', { ...event.data.subscription, swVersion: 2 }))
|
||||
@ -273,17 +249,13 @@ export function onMessage (sw) {
|
||||
return event.waitUntil(storage.removeItem('subscription'))
|
||||
}
|
||||
if (event.data.action === CLEAR_NOTIFICATIONS) {
|
||||
return event.waitUntil((async () => {
|
||||
let notifications = []
|
||||
try {
|
||||
notifications = await sw.registration.getNotifications()
|
||||
} catch (err) {
|
||||
console.error('failed to get notifications')
|
||||
}
|
||||
const promises = []
|
||||
promises.push(sw.registration.getNotifications().then((notifications) => {
|
||||
notifications.forEach(notification => notification.close())
|
||||
activeCount = 0
|
||||
return await clearAppBadge(sw)
|
||||
})())
|
||||
}))
|
||||
promises.push(clearAppBadge(sw))
|
||||
activeCount = 0
|
||||
event.waitUntil(Promise.all(promises))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Wallets
|
||||
|
||||
Every wallet that you can see at [/settings/wallets](https://stacker.news/settings/wallets) is implemented as a plugin in this directory.
|
||||
Every wallet that you can see at [/wallets](https://stacker.news/wallets) is implemented as a plugin in this directory.
|
||||
|
||||
This README explains how you can add another wallet for use with Stacker News.
|
||||
|
||||
@ -59,11 +59,11 @@ This is an optional value. Set this to true if your wallet needs to be configure
|
||||
|
||||
- `fields: WalletField[]`
|
||||
|
||||
Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/settings/wallets/lnbits](https://stacker.news/settings/walletslnbits).
|
||||
Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/wallets/lnbits](https://stacker.news/walletslnbits).
|
||||
|
||||
- `card: WalletCard`
|
||||
|
||||
Wallet cards are the components you can see at [/settings/wallets](https://stacker.news/settings/wallets). This property customizes this card for this wallet.
|
||||
Wallet cards are the components you can see at [/wallets](https://stacker.news/wallets). This property customizes this card for this wallet.
|
||||
|
||||
- `validate: (config) => void`
|
||||
|
||||
|
@ -2,7 +2,7 @@ import { useMe } from '@/components/me'
|
||||
import useVault from '@/components/vault/use-vault'
|
||||
import { useCallback } from 'react'
|
||||
import { canReceive, canSend, getStorageKey, saveWalletLocally, siftConfig, upsertWalletVariables } from './common'
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import { generateMutation } from './graphql'
|
||||
import { REMOVE_WALLET } from '@/fragments/wallet'
|
||||
import { useWalletLogger } from '@/wallets/logger'
|
||||
@ -18,6 +18,7 @@ export function useWalletConfigurator (wallet) {
|
||||
const logger = useWalletLogger(wallet)
|
||||
const [upsertWallet] = useMutation(generateMutation(wallet?.def))
|
||||
const [removeWallet] = useMutation(REMOVE_WALLET)
|
||||
const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`)
|
||||
|
||||
const _saveToServer = useCallback(async (serverConfig, clientConfig, validateLightning) => {
|
||||
const variables = await upsertWalletVariables(
|
||||
@ -116,6 +117,7 @@ export function useWalletConfigurator (wallet) {
|
||||
}
|
||||
|
||||
if (newCanSend) {
|
||||
disableFreebies().catch(console.error)
|
||||
if (oldCanSend) {
|
||||
logger.ok('details for sending updated')
|
||||
} else {
|
||||
@ -130,7 +132,7 @@ export function useWalletConfigurator (wallet) {
|
||||
logger.info('details for sending deleted')
|
||||
}
|
||||
}, [isActive, wallet.def, wallet.config, _saveToServer, _saveToLocal, _validate,
|
||||
_detachFromLocal, _detachFromServer])
|
||||
_detachFromLocal, _detachFromServer, disableFreebies])
|
||||
|
||||
const detach = useCallback(async () => {
|
||||
if (isActive) {
|
||||
|
@ -2,11 +2,11 @@ import { createHodlInvoice, parsePaymentRequest } from 'ln-service'
|
||||
import { estimateRouteFee, getBlockHeight } from '../api/lnd'
|
||||
import { toBigInt, toPositiveBigInt, toPositiveNumber } from '@/lib/format'
|
||||
|
||||
const MIN_OUTGOING_MSATS = BigInt(900) // the minimum msats we'll allow for the outgoing invoice
|
||||
const MAX_OUTGOING_MSATS = BigInt(900_000_000) // the maximum msats we'll allow for the outgoing invoice
|
||||
const MIN_OUTGOING_MSATS = BigInt(700) // the minimum msats we'll allow for the outgoing invoice
|
||||
const MAX_OUTGOING_MSATS = BigInt(700_000_000) // the maximum msats we'll allow for the outgoing invoice
|
||||
const MAX_EXPIRATION_INCOMING_MSECS = 900_000 // the maximum expiration time we'll allow for the incoming invoice
|
||||
const INCOMING_EXPIRATION_BUFFER_MSECS = 300_000 // the buffer enforce for the incoming invoice expiration
|
||||
const MAX_OUTGOING_CLTV_DELTA = 500 // the maximum cltv delta we'll allow for the outgoing invoice
|
||||
const MAX_OUTGOING_CLTV_DELTA = 1000 // the maximum cltv delta we'll allow for the outgoing invoice
|
||||
export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice
|
||||
const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request
|
||||
const MAX_FEE_ESTIMATE_PERCENT = 3n // the maximum fee relative to outgoing we'll allow for the fee estimate
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { notifyEarner } from '@/lib/webPush'
|
||||
import createPrisma from '@/lib/create-prisma'
|
||||
import { SN_NO_REWARDS_IDS } from '@/lib/constants'
|
||||
import { PAID_ACTION_PAYMENT_METHODS, SN_NO_REWARDS_IDS, USER_ID } from '@/lib/constants'
|
||||
import performPaidAction from '@/api/paidAction'
|
||||
|
||||
const TOTAL_UPPER_BOUND_MSATS = 1_000_000_000
|
||||
|
||||
@ -187,3 +188,15 @@ function earnStmts (data, { models }) {
|
||||
}
|
||||
})]
|
||||
}
|
||||
|
||||
const DAILY_STIMULUS_SATS = 75_000
|
||||
export async function earnRefill ({ models, lnd }) {
|
||||
return await performPaidAction('DONATE',
|
||||
{ sats: DAILY_STIMULUS_SATS },
|
||||
{
|
||||
models,
|
||||
me: { id: USER_ID.sn },
|
||||
lnd,
|
||||
forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT
|
||||
})
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
} from './wallet'
|
||||
import { repin } from './repin'
|
||||
import { trust } from './trust'
|
||||
import { earn } from './earn'
|
||||
import { earn, earnRefill } from './earn'
|
||||
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
|
||||
import { indexItem, indexAllItems } from './search'
|
||||
import { timestampItem } from './ots'
|
||||
@ -129,6 +129,7 @@ async function work () {
|
||||
await boss.work('trust', jobWrapper(trust))
|
||||
await boss.work('timestampItem', jobWrapper(timestampItem))
|
||||
await boss.work('earn', jobWrapper(earn))
|
||||
await boss.work('earnRefill', jobWrapper(earnRefill))
|
||||
await boss.work('streak', jobWrapper(computeStreaks))
|
||||
await boss.work('checkStreak', jobWrapper(checkStreak))
|
||||
await boss.work('nip57', jobWrapper(nip57))
|
||||
|
@ -27,9 +27,11 @@ const ITEM_SEARCH_FIELDS = gql`
|
||||
remote
|
||||
upvotes
|
||||
sats
|
||||
credits
|
||||
boost
|
||||
lastCommentAt
|
||||
commentSats
|
||||
commentCredits
|
||||
path
|
||||
ncomments
|
||||
}`
|
||||
|
@ -5,7 +5,7 @@ import gql from 'graphql-tag'
|
||||
|
||||
export async function autoPost ({ data: item, models, apollo, lnd, boss }) {
|
||||
return await performPaidAction('ITEM_CREATE',
|
||||
{ ...item, subName: 'meta', userId: USER_ID.sn, apiKey: true },
|
||||
{ subName: 'meta', ...item, userId: USER_ID.sn, apiKey: true },
|
||||
{
|
||||
models,
|
||||
me: { id: USER_ID.sn },
|
||||
|
Loading…
x
Reference in New Issue
Block a user