backend payment optimism (#1195)

* wip backend optimism

* another inch

* make action state transitions only happen once

* another inch

* almost ready for testing

* use interactive txs

* another inch

* ready for basic testing

* lint fix

* inches

* wip item update

* get item update to work

* donate and downzap

* inchy inch

* fix territory paid actions

* wip usePaidMutation

* usePaidMutation error handling

* PENDING_HELD and HELD transitions, gql paidAction return types

* mostly working pessimism

* make sure invoice field is present in optimisticResponse

* inches

* show optimistic values to current me

* first pass at notifications and payment status reporting

* fix migration to have withdrawal hash

* reverse optimism on payment failure

* Revert "Optimistic updates via pending sats in item context (#1229)"

This reverts commit 93713b33df.

* add onCompleted to usePaidMutation

* onPaid and onPayError for new comments

* use 'IS DISTINCT FROM' for NULL invoiceActionState columns

* make usePaidMutation easier to read

* enhance invoice qr

* prevent actions on unpaid items

* allow navigation to action's invoice

* retry create item

* start edit window after item is paid for

* fix ux of retries from notifications

* refine retries

* fix optimistic downzaps

* remember item updates can't be retried

* store reference to action item in invoice

* remove invoice modal layout shift

* fix destructuring

* fix zap undos

* make sure ItemAct is paid in aggregate queries

* dont toast on long press zap undo

* fix delete and remindme bots

* optimistic poll votes with retries

* fix retry notifications and invoice item context

* fix pessimisitic typo

* item mentions and mention notifications

* dont show payment retry on item popover

* make bios work

* refactor paidAction transitions

* remove stray console.log

* restore docker compose nwc settings

* add new todos

* persist qr modal on post submission + unify item form submission

* fix post edit threshold

* make bounty payments work

* make job posting work

* remove more store procedure usage ... document serialization concerns

* dont use dynamic imports for paid action modules

* inline comment denormalization

* create item starts with median votes

* fix potential of serialization anomalies in zaps

* dont trigger notification indicator on successful paid action invoices

* ignore invoiceId on territory actions and add optimistic concurrency control

* begin docs for paid actions

* better error toasts and fix apollo cache warnings

* small documentation enhancements

* improve paid action docs

* optimistic concurrency control for territory updates

* use satsToMsats and msatsToSats helpers

* explictly type raw query template parameters

* improve consistency of nested relation names

* complete paid action docs

* useEffect for canEdit on payment

* make sure invoiceId is provided when required

* don't return null when expecting array

* remove buy credits

* move verifyPayment to paidAction

* fix comments invoicePaidAt time zone

* close nwc connections once

* grouped logs for paid actions

* stop invoiceWaitUntilPaid if not attempting to pay

* allow actionState to transition directly from HELD to PAID

* make paid mutation wait until pessimistic are fully paid

* change button text when form submits/pays

* pulsing form submit button

* ignore me in notification indicator for territory subscription

* filter unpaid items from more queries

* fix donation stike timing

* fix pending poll vote

* fix recent item notifcation padding

* no default form submitting button text

* don't show paying on submit button on free edits

* fix territory autorenew with fee credits

* reorg readme

* allow jobs to be editted forever

* fix image uploads

* more filter fixes for aggregate views

* finalize paid action invoice expirations

* remove unnecessary async

* keep clientside cache normal/consistent

* add more detail to paid action doc

* improve paid action table

* remove actionType guard

* fix top territories

* typo api/paidAction/README.md

Co-authored-by: ekzyis <ek@stacker.news>

* typo components/use-paid-mutation.js

Co-authored-by: ekzyis <ek@stacker.news>

* Apply suggestions from code review

Co-authored-by: ekzyis <ek@stacker.news>

* encorporate ek feeback

* more ek suggestions

* fix 'cost to post' hover on items

* Apply suggestions from code review

Co-authored-by: ekzyis <ek@stacker.news>

---------

Co-authored-by: ekzyis <ek@stacker.news>
This commit is contained in:
Keyan 2024-07-01 12:02:29 -05:00 committed by GitHub
parent 30e29f709d
commit ca11ac9fb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
94 changed files with 4986 additions and 2372 deletions

204
api/paidAction/README.md Normal file
View File

@ -0,0 +1,204 @@
# Paid Actions
Paid actions are actions that require payments to perform. Given that we support several payment flows, some of which require more than one round of communication either with LND or the client, and several paid actions, we have this plugin-like interface to easily add new paid actions.
## Payment Flows
There are three payment flows:
### Fee credits
The stacker has enough fee credits to pay for the action. This is the simplest flow and is similar to a normal request.
### Optimistic
The optimistic flow is useful for actions that require immediate feedback to the client, but don't require the action to be immediately visible to everyone else.
For paid actions that support it, if the stacker doesn't have enough fee credits, we store the action in a `PENDING` state on the server, which is visible only to the stacker, then return a payment request to the client. The client then pays the invoice however and whenever they wish, and the server monitors payment progress. If the payment succeeds, the action is executed fully becoming visible to everyone and is marked as `PAID`. Otherwise, the action is marked as `FAILED`, the client is notified the payment failed and the payment can be retried.
<details>
<summary>Internals</summary>
Internally, optimistic flows make use of a state machine that's transitioned by the invoice payment progress. All optimistic actions start in a `PENDING` state and have the following transitions:
- `PENDING` -> `PAID`: when the invoice is paid
- `PENDING` -> `FAILED`: when the invoice expires or is cancelled
- `FAILED` -> `RETRYING`: when the invoice for the action is replaced with a new invoice
</details>
### Pessimistic
For paid actions that don't support optimistic actions (or when the stacker is `@anon`), if the client doesn't have enough fee credits, we return a payment request to the client without storing the action. After the client pays the invoice, the client resends the action with proof of payment and action is executed fully. Pessimistic actions require the client to wait for the payment to complete before being visible to them and everyone else.
Internally, pessimistic flows use hold invoices. If the action doesn't succeed, the payment is cancelled and it's as if the payment never happened (ie it's a lightning native refund mechanism).
<details>
<summary>Internals</summary>
Internally, pessimistic flows make use of a state machine that's transitioned by the invoice payment progress much like optimistic flows, but with extra steps. All pessimistic actions start in a `PENDING_HELD` state and has the following transitions:
- `PENDING_HELD` -> `HELD`: when the invoice is paid, but the action is not yet executed
- `HELD` -> `PAID`: when the invoice is paid
- `PENDING_HELD` -> `FAILED`: when the invoice for the action expires or is cancelled
- `HELD` -> `FAILED`: when the action fails after the invoice is paid
</details>
### Table of existing paid actions and their supported flows
| action | fee credits | optimistic | pessimistic | anonable | qr payable | p2p wrapped | side effects |
| ----------------- | ----------- | ---------- | ----------- | -------- | ---------- | ----------- | ------------ |
| zaps | x | x | x | x | x | x | x |
| posts | x | x | x | x | x | | x |
| comments | x | x | x | x | x | | x |
| downzaps | x | x | | | x | | x |
| poll votes | x | x | | | x | | |
| territory actions | x | | x | | x | | |
| donations | x | | x | x | x | | |
| update posts | x | | x | | x | | x |
| update comments | x | | x | | x | | x |
## Paid Action Interface
Each paid action is implemented in its own file in the `paidAction` directory. Each file exports a module with the following properties:
### Boolean flags
- `anonable`: can be performed anonymously
- `supportsPessimism`: supports a pessimistic payment flow
- `supportsOptimism`: supports an optimistic payment flow
#### Functions
All functions have the following signature: `function(args: Object, context: Object): Promise`
- `getCost`: returns the cost of the action in msats as a `BigInt`
- `perform`: performs the action
- returns: an object with the result of the action as defined in the `graphql` schema
- if the action supports optimism and an `invoiceId` is provided, the action should be performed optimistically
- any action data that needs to be hidden while it's pending, should store in its rows a `PENDING` state along with its `invoiceId`
- it can optionally store in the invoice with the `invoiceId` the `actionId` to be able to link the action with the invoice regardless of retries
- `onPaid`: called when the action is paid
- if the action does not support optimism, this function is optional
- this function should be used to mark the rows created in `perform` as `PAID` and perform any other side effects of the action (like notifications or denormalizations)
- `onFail`: called when the action fails
- if the action does not support optimism, this function is optional
- this function should be used to mark the rows created in `perform` as `FAILED`
- `retry`: called when the action is retried with any new invoice information
- return: an object with the result of the action as defined in the `graphql` schema (same as `perform`)
- this function is called when an optimistic action is retried
- it's passed the original `invoiceId` and the `newInvoiceId`
- this function should update the rows created in `perform` to contain the new `newInvoiceId` and remark the row as `PENDING`
- `describe`: returns a description as a string of the action
- for actions that require generating an invoice, and for stackers that don't hide invoice descriptions, this is used in the invoice description
#### Function arguments
`args` contains the arguments for the action as defined in the `graphql` schema. If the action is optimistic or pessimistic, `args` will contain an `invoiceId` field which can be stored alongside the paid action's data. If this is a call to `retry`, `args` will contain the original `invoiceId` and `newInvoiceId` fields.
`context` contains the following fields:
- `user`: the user performing the action (null if anonymous)
- `cost`: the cost of the action in msats as a `BigInt`
- `tx`: the current transaction (for anything that needs to be done atomically with the payment)
- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment)
- `lnd`: the current lnd client
## `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).
### This is a big deal
1. If you read from the database and intend to use that data to write to the database, and it's possible that a concurrent transaction could change the data you've read (it usually is), you need to be prepared to handle that.
2. This applies to **ALL**, and I really mean **ALL**, read data regardless of how you read the data within the `read committed` transaction:
- independent statements
- `WITH` queries (CTEs) in the same statement
- subqueries in the same statement
### How to handle it
1. take row level locks on the rows you read, using something like a `SELECT ... FOR UPDATE` statement
- NOTE: this does not protect against missing concurrent inserts. It only prevents concurrent updates to the rows you've already read.
- read about row level locks available in postgres: https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS
2. check that the data you read is still valid before writing it back to the database i.e. optimistic concurrency control
- NOTE: this does not protect against missing concurrent inserts. It only prevents concurrent updates to the rows you've already read.
3. avoid having to read data from one row to modify the data of another row all together
### Example
Let's say you are aggregating total sats for an item from a table `zaps` and updating the total sats for that item in another table `item_zaps`. Two 100 sat zaps are requested for the same item at the same time in two concurrent transactions. The total sats for the item should be 200, but because of the way `read committed` works, the following statements lead to a total sats of 100:
*the statements here are listed in the order they are executed, but each transaction is happening concurrently*
#### Incorrect
```sql
-- transaction 1
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1;
-- total_sats is 100
-- transaction 2
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1;
-- total_sats is still 100, because transaction 1 hasn't committed yet
-- transaction 1
UPDATE item_zaps SET sats = total_sats WHERE item_id = 1;
-- sets sats to 100
-- transaction 2
UPDATE item_zaps SET sats = total_sats WHERE item_id = 1;
-- sets sats to 100
COMMIT;
-- transaction 1
COMMIT;
-- item_zaps.sats is 100, but we would expect it to be 200
```
Note that row level locks wouldn't help in this case, because we can't lock the rows that the transactions doesn't know to exist yet.
#### Subqueries are still incorrect
```sql
-- transaction 1
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
UPDATE item_zaps SET sats = (SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1) WHERE item_id = 1;
-- item_zaps.sats is 100
-- transaction 2
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
UPDATE item_zaps SET sats = (SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1) WHERE item_id = 1;
-- item_zaps.sats is still 100, because transaction 1 hasn't committed yet
-- transaction 1
COMMIT;
-- transaction 2
COMMIT;
-- item_zaps.sats is 100, but we would expect it to be 200
```
Note that while the `UPDATE` transaction 2's update statement will block until transaction 1 commits, the subquery is computed before it blocks and is not re-evaluated after the block.
#### Correct
```sql
-- transaction 1
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
-- transaction 2
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
-- transaction 1
UPDATE item_zaps SET sats = sats + 100 WHERE item_id = 1;
-- transaction 2
UPDATE item_zaps SET sats = sats + 100 WHERE item_id = 1;
COMMIT;
-- transaction 1
COMMIT;
-- item_zaps.sats is 200
```
The above works because `UPDATE` takes a lock on the rows it's updating, so transaction 2 will block until transaction 1 commits, and once transaction 2 is unblocked, it will re-evaluate the `sats` value of the row it's updating.
#### More resources
- https://stackoverflow.com/questions/61781595/postgres-read-commited-doesnt-re-read-updated-row?noredirect=1#comment109279507_61781595
- https://www.cybertec-postgresql.com/en/transaction-anomalies-with-select-for-update/
From the [postgres docs](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED):
> UPDATE, DELETE, SELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the command start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the second updater can proceed with updating the originally found row. If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row. The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition. If so, the second updater proceeds with its operation using the updated version of the row. In the case of SELECT FOR UPDATE and SELECT FOR SHARE, this means it is the updated version of the row that is locked and returned to the client.
From the [postgres source docs](https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/executor/README#l350):
> It is also possible that there are relations in the query that are not to be locked (they are neither the UPDATE/DELETE/MERGE target nor specified to be locked in SELECT FOR UPDATE/SHARE). When re-running the test query ***we want to use the same rows*** from these relations that were joined to the locked rows.

View File

@ -0,0 +1,26 @@
// XXX we don't use this yet ...
// it's just showing that even buying credits
// can eventually be a paid action
import { USER_ID } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
export const anonable = false
export const supportsPessimism = false
export const supportsOptimism = true
export async function getCost ({ amount }) {
return satsToMsats(amount)
}
export async function onPaid ({ invoice }, { tx }) {
return await tx.users.update({
where: { id: invoice.userId },
data: { balance: { increment: invoice.msatsReceived } }
})
}
export async function describe ({ amount }, { models, me }) {
const user = await models.user.findUnique({ where: { id: me?.id ?? USER_ID.anon } })
return `SN: buying credits for @${user.name}`
}

25
api/paidAction/donate.js Normal file
View File

@ -0,0 +1,25 @@
import { USER_ID } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
export const anonable = true
export const supportsPessimism = true
export const supportsOptimism = false
export async function getCost ({ sats }) {
return satsToMsats(sats)
}
export async function perform ({ sats }, { me, tx }) {
await tx.donation.create({
data: {
sats,
userId: me?.id ?? USER_ID.anon
}
})
return { sats }
}
export async function describe (args, context) {
return 'SN: donate to rewards pool'
}

79
api/paidAction/downZap.js Normal file
View File

@ -0,0 +1,79 @@
import { msatsToSats, satsToMsats } from '@/lib/format'
export const anonable = false
export const supportsPessimism = false
export const supportsOptimism = true
export async function getCost ({ sats }) {
return satsToMsats(sats)
}
export async function perform ({ invoiceId, sats, id: itemId }, { me, cost, tx }) {
itemId = parseInt(itemId)
let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
// store a reference to the item in the invoice
await tx.invoice.update({
where: { id: invoiceId },
data: { actionId: itemId }
})
}
const itemAct = await tx.itemAct.create({
data: { msats: cost, itemId, userId: me.id, act: 'DONT_LIKE_THIS', ...invoiceData }
})
const [{ path }] = await tx.$queryRaw`SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
return { id: itemId, sats, act: 'DONT_LIKE_THIS', path, actId: itemAct.id }
}
export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
const [{ id, path }] = await tx.$queryRaw`
SELECT "Item".id, ltree2text(path) as path
FROM "Item"
JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId"
WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER`
return { id, sats: msatsToSats(cost), act: 'DONT_LIKE_THIS', path }
}
export async function onPaid ({ invoice, actId }, { tx }) {
let itemAct
if (invoice) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id } })
} else if (actId) {
itemAct = await tx.itemAct.findUnique({ where: { id: actId } })
} else {
throw new Error('No invoice or actId')
}
const msats = BigInt(itemAct.msats)
const sats = msatsToSats(msats)
// denormalize downzaps
await tx.$executeRaw`
WITH zapper AS (
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER
), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
ON CONFLICT ("itemId", "userId") DO UPDATE
SET "downZapSats" = "ItemUserAgg"."downZapSats" + ${sats}::INTEGER, updated_at = now()
RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
)
UPDATE "Item"
SET "weightedDownVotes" = "weightedDownVotes" + (zapper.trust * zap.log_sats)
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
}
export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}
export async function describe ({ itemId, sats }, { cost, actionId }) {
return `SN: downzap of ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
}

318
api/paidAction/index.js Normal file
View File

@ -0,0 +1,318 @@
import { createHodlInvoice, createInvoice, settleHodlInvoice } from 'ln-service'
import { datePivot } from '@/lib/time'
import { USER_ID } from '@/lib/constants'
import { createHmac } from '../resolvers/wallet'
import { Prisma } from '@prisma/client'
import { timingSafeEqual } from 'crypto'
import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate'
import * as ZAP from './zap'
import * as DOWN_ZAP from './downZap'
import * as POLL_VOTE from './pollVote'
import * as TERRITORY_CREATE from './territoryCreate'
import * as TERRITORY_UPDATE from './territoryUpdate'
import * as TERRITORY_BILLING from './territoryBilling'
import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate'
export const paidActions = {
ITEM_CREATE,
ITEM_UPDATE,
ZAP,
DOWN_ZAP,
POLL_VOTE,
TERRITORY_CREATE,
TERRITORY_UPDATE,
TERRITORY_BILLING,
TERRITORY_UNARCHIVE,
DONATE
}
export default async function performPaidAction (actionType, args, context) {
try {
const { me, models, hash, hmac, forceFeeCredits } = context
const paidAction = paidActions[actionType]
console.group('performPaidAction', actionType, args)
if (!paidAction) {
throw new Error(`Invalid action type ${actionType}`)
}
if (!me && !paidAction.anonable) {
throw new Error('You must be logged in to perform this action')
}
context.user = me ? await models.user.findUnique({ where: { id: me.id } }) : null
context.cost = await paidAction.getCost(args, context)
if (hash || hmac || !me) {
console.log('hash or hmac provided, or anon, performing pessimistic action')
return await performPessimisticAction(actionType, args, context)
}
const isRich = context.cost <= context.user.msats
if (isRich) {
try {
console.log('enough fee credits available, performing fee credit action')
return await performFeeCreditAction(actionType, args, context)
} catch (e) {
console.error('fee credit action failed', e)
// if we fail to do the action with fee credits, but the cost is 0, we should bail
if (context.cost === 0n) {
throw e
}
// if we fail to do the action with fee credits, we should fall back to optimistic
if (!paidAction.supportsOptimism) {
console.error('action does not support optimism and fee credits failed, performing pessimistic action')
return await performPessimisticAction(actionType, args, context)
}
}
} else {
// this is set if the worker executes a paid action in behalf of a user.
// in that case, only payment via fee credits is possible
// since there is no client to which we could send an invoice.
// example: automated territory billing
if (forceFeeCredits) {
throw new Error('forceFeeCredits is set, but user does not have enough fee credits')
}
if (!paidAction.supportsOptimism) {
console.log('not enough fee credits available, optimism not supported, performing pessimistic action')
return await performPessimisticAction(actionType, args, context)
}
}
if (paidAction.supportsOptimism) {
console.log('performing optimistic action')
return await performOptimisticAction(actionType, args, context)
}
throw new Error(`This action ${actionType} could not be done`)
} catch (e) {
console.error('performPaidAction failed', e)
throw e
} finally {
console.groupEnd()
}
}
async function performFeeCreditAction (actionType, args, context) {
const { me, models, cost } = context
const action = paidActions[actionType]
return await models.$transaction(async tx => {
context.tx = tx
await tx.user.update({
where: {
id: me.id
},
data: {
msats: {
decrement: cost
}
}
})
const result = await action.perform(args, context)
await action.onPaid?.(result, context)
return {
result,
paymentMethod: 'FEE_CREDIT'
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}
async function performOptimisticAction (actionType, args, context) {
const { models } = context
const action = paidActions[actionType]
return await models.$transaction(async tx => {
context.tx = tx
context.optimistic = true
const invoice = await createDbInvoice(actionType, args, context)
return {
invoice,
result: await action.perform?.({ invoiceId: invoice.id, ...args }, context),
paymentMethod: 'OPTIMISTIC'
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}
async function performPessimisticAction (actionType, args, context) {
const { models, lnd } = context
const action = paidActions[actionType]
if (!action.supportsPessimism) {
throw new Error(`This action ${actionType} does not support pessimistic invoicing`)
}
if (context.hmac) {
return await models.$transaction(async tx => {
context.tx = tx
// make sure the invoice is HELD
const invoice = await verifyPayment(context)
args.invoiceId = invoice.id
await settleHodlInvoice({ secret: invoice.preimage, lnd })
return {
result: await action.perform(args, context),
paymentMethod: 'PESSIMISTIC'
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
} else {
// just create the invoice and complete action when it's paid
return {
invoice: await createDbInvoice(actionType, args, context),
paymentMethod: 'PESSIMISTIC'
}
}
}
export async function retryPaidAction (actionType, args, context) {
const { models, me } = context
const { invoiceId } = args
const action = paidActions[actionType]
if (!action) {
throw new Error(`retryPaidAction - invalid action type ${actionType}`)
}
if (!me) {
throw new Error(`retryPaidAction - must be logged in ${actionType}`)
}
if (!action.supportsOptimism) {
throw new Error(`retryPaidAction - action does not support optimism ${actionType}`)
}
if (!action.retry) {
throw new Error(`retryPaidAction - action does not support retrying ${actionType}`)
}
if (!invoiceId) {
throw new Error(`retryPaidAction - missing invoiceId ${actionType}`)
}
context.user = await models.user.findUnique({ where: { id: me.id } })
return await models.$transaction(async tx => {
context.tx = tx
context.optimistic = true
// update the old invoice to RETRYING, so that it's not confused with FAILED
const { msatsRequested, actionId } = await tx.invoice.update({
where: {
id: invoiceId,
actionState: 'FAILED'
},
data: {
actionState: 'RETRYING'
}
})
context.cost = BigInt(msatsRequested)
context.actionId = actionId
// create a new invoice
const invoice = await createDbInvoice(actionType, args, context)
return {
result: await action.retry({ invoiceId, newInvoiceId: invoice.id }, context),
invoice,
paymentMethod: 'OPTIMISTIC'
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}
const OPTIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
const PESSIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
async function createDbInvoice (actionType, args, context) {
const { user, models, tx, lnd, cost, optimistic, actionId } = context
const action = paidActions[actionType]
const db = tx ?? models
const [createLNDInvoice, expirePivot, actionState] = optimistic
? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE, 'PENDING']
: [createHodlInvoice, PESSIMISTIC_INVOICE_EXPIRE, 'PENDING_HELD']
if (cost < 1000n) {
// sanity check
throw new Error('The cost of the action must be at least 1 sat')
}
const expiresAt = datePivot(new Date(), expirePivot)
const lndInv = await createLNDInvoice({
description: user?.hideInvoiceDesc ? undefined : await action.describe(args, context),
lnd,
mtokens: String(cost),
expires_at: expiresAt
})
const invoice = await db.invoice.create({
data: {
hash: lndInv.id,
msatsRequested: cost,
preimage: optimistic ? undefined : lndInv.secret,
bolt11: lndInv.request,
userId: user?.id || USER_ID.anon,
actionType,
actionState,
expiresAt,
actionId
}
})
// 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)
VALUES ('checkInvoice',
jsonb_build_object('hash', ${lndInv.id}::TEXT), 21, true,
${expiresAt}::TIMESTAMP WITH TIME ZONE,
${expiresAt}::TIMESTAMP WITH TIME ZONE - now() + interval '10m', 100)`
// the HMAC is only returned during invoice creation
// this makes sure that only the person who created this invoice
// has access to the HMAC
invoice.hmac = createHmac(invoice.hash)
return invoice
}
export async function verifyPayment ({ hash, hmac, models, cost }) {
if (!hash) {
throw new Error('hash required')
}
if (!hmac) {
throw new Error('hmac required')
}
const hmac2 = createHmac(hash)
if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
throw new Error('hmac invalid')
}
const invoice = await models.invoice.findUnique({
where: {
hash,
actionState: 'HELD'
}
})
if (!invoice) {
throw new Error('invoice not found')
}
if (invoice.msatsReceived < cost) {
throw new Error('invoice amount too low')
}
return invoice
}

View File

@ -0,0 +1,242 @@
import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, USER_ID } from '@/lib/constants'
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers } from '@/lib/webPush'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { satsToMsats } from '@/lib/format'
export const anonable = true
export const supportsPessimism = true
export const supportsOptimism = true
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, user }) {
const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } })
const baseCost = sub ? satsToMsats(sub.baseCost) : 1000n
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + image fees + boost
const [{ cost }] = await models.$queryRaw`
SELECT ${baseCost}::INTEGER
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${user?.id || USER_ID.anon}::INTEGER,
${user?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
* ${user ? 1 : 100}::INTEGER
+ (SELECT "nUnpaid" * "imageFeeMsats"
FROM image_fees_info(${user?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ ${satsToMsats(boost)}::INTEGER as cost`
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon, and cost must be greater than user's balance
const freebie = (parentId || bio || sub?.allowFreebies) && cost <= baseCost && !!user && cost > user?.msats
return freebie ? BigInt(0) : BigInt(cost)
}
export async function perform (args, context) {
const { invoiceId, parentId, uploadIds = [], forwardUsers = [], options: pollOptions = [], boost = 0, ...data } = args
const { tx, me, cost } = context
const boostMsats = satsToMsats(boost)
let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
await tx.upload.updateMany({
where: { id: { in: uploadIds } },
data: invoiceData
})
}
const itemActs = []
if (boostMsats > 0) {
itemActs.push({
msats: boostMsats, act: 'BOOST', userId: data.userId, ...invoiceData
})
}
if (cost > 0) {
itemActs.push({
msats: cost - boostMsats, act: 'FEE', userId: data.userId, ...invoiceData
})
} else {
data.freebie = true
}
const mentions = await getMentions(args, context)
const itemMentions = await getItemMentions(args, context)
// start with median vote
if (me) {
const [row] = await tx.$queryRaw`SELECT
COALESCE(percentile_cont(0.5) WITHIN GROUP(
ORDER BY "weightedVotes" - "weightedDownVotes"), 0)
AS median FROM "Item" WHERE "userId" = ${me.id}::INTEGER`
if (row?.median < 0) {
data.weightedDownVotes = -row.median
}
}
const itemData = {
parentId: parentId ? parseInt(parentId) : null,
...data,
...invoiceData,
boost,
threadSubscriptions: {
createMany: {
data: [
{ userId: data.userId },
...forwardUsers.map(({ userId }) => ({ userId }))
]
}
},
itemForwards: {
createMany: {
data: forwardUsers
}
},
pollOptions: {
createMany: {
data: pollOptions.map(option => ({ option }))
}
},
itemUploads: {
create: uploadIds.map(id => ({ uploadId: id }))
},
itemActs: {
createMany: {
data: itemActs
}
},
mentions: {
createMany: {
data: mentions
}
},
itemReferrers: {
create: itemMentions
}
}
let item
if (data.bio && me) {
item = (await tx.user.update({
where: { id: data.userId },
include: { bio: true },
data: {
bio: {
create: itemData
}
}
})).bio
} else {
item = await tx.item.create({ data: itemData })
}
// store a reference to the item in the invoice
if (invoiceId) {
await tx.invoice.update({
where: { id: invoiceId },
data: { actionId: item.id }
})
}
await performBotBehavior(item, context)
// ltree is unsupported in Prisma, so we have to query it manually (FUCK!)
return (await tx.$queryRaw`
SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt"
FROM "Item" WHERE id = ${item.id}::INTEGER`
)[0]
}
export async function retry ({ invoiceId, newInvoiceId }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
await tx.item.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
await tx.upload.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
return (await tx.$queryRaw`
SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt"
FROM "Item" WHERE "invoiceId" = ${newInvoiceId}::INTEGER`
)[0]
}
export async function onPaid ({ invoice, id }, context) {
const { models, tx } = context
let item
if (invoice) {
item = await tx.item.findFirst({
where: { invoiceId: invoice.id },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } },
user: true
}
})
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
await tx.item.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID', invoicePaidAt: new Date() } })
await tx.upload.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID', paid: true } })
} else if (id) {
item = await tx.item.findUnique({
where: { id },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } },
user: true,
itemUploads: { include: { upload: true } }
}
})
await tx.upload.updateMany({
where: { id: { in: item.itemUploads.map(({ uploadId }) => uploadId) } },
data: {
paid: true
}
})
} else {
throw new Error('No item found')
}
await tx.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority)
VALUES ('timestampItem', jsonb_build_object('id', ${item.id}::INTEGER), now() + interval '10 minutes', -2)`
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', ${item.id}::INTEGER), 21, true, now() + interval '5 seconds')`
// TODO: referals for boost
if (item.parentId) {
// denormalize ncomments and "weightedComments" for ancestors, and insert into reply table
await tx.$executeRaw`
WITH comment AS (
SELECT *
FROM "Item"
WHERE id = ${item.id}::INTEGER
), ancestors AS (
UPDATE "Item"
SET ncomments = "Item".ncomments + 1,
"weightedComments" = "Item"."weightedComments" +
CASE WHEN comment."userId" = "Item"."userId" THEN 0 ELSE ${item.user.trust}::FLOAT END
FROM comment
WHERE "Item".path @> comment.path AND "Item".id <> comment.id
RETURNING "Item".*
)
INSERT INTO "Reply" (created_at, updated_at, "ancestorId", "ancestorUserId", "itemId", "userId", level)
SELECT comment.created_at, comment.updated_at, ancestors.id, ancestors."userId",
comment.id, comment."userId", nlevel(comment.path) - nlevel(ancestors.path)
FROM ancestors, comment
WHERE ancestors."userId" <> comment."userId"`
notifyItemParents({ item, me: item.userId, models }).catch(console.error)
}
for (const { userId } of item.mentions) {
notifyMention({ models, item, userId }).catch(console.error)
}
for (const { referee } of item.itemReferrers) {
notifyItemMention({ models, referrerItem: item, refereeItem: referee }).catch(console.error)
}
notifyUserSubscribers({ models, item }).catch(console.error)
notifyTerritorySubscribers({ models, item }).catch(console.error)
}
export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
await tx.item.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
await tx.upload.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}
export async function describe ({ parentId }, context) {
return `SN: create ${parentId ? `reply to #${parentId}` : 'item'}`
}

View File

@ -0,0 +1,155 @@
import { USER_ID } from '@/lib/constants'
import { imageFeesInfo } from '../resolvers/image'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { notifyItemMention, notifyMention } from '@/lib/webPush'
import { satsToMsats } from '@/lib/format'
export const anonable = false
export const supportsPessimism = true
export const supportsOptimism = false
export async function getCost ({ id, boost = 0, uploadIds }, { me, models }) {
// the only reason updating items costs anything is when it has new uploads
// or more boost
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
const { totalFeesMsats } = await imageFeesInfo(uploadIds, { models, me })
return BigInt(totalFeesMsats) + satsToMsats(boost - (old.boost || 0))
}
export async function perform (args, context) {
const { id, boost = 0, uploadIds = [], options: pollOptions = [], forwardUsers: itemForwards = [], invoiceId, ...data } = args
const { tx, me, models } = context
const old = await tx.item.findUnique({
where: { id: parseInt(id) },
include: {
threadSubscriptions: true,
mentions: true,
itemForwards: true,
itemReferrers: true,
itemUploads: true
}
})
const boostMsats = satsToMsats(boost - (old.boost || 0))
const itemActs = []
if (boostMsats > 0) {
itemActs.push({
msats: boostMsats, act: 'BOOST', userId: me?.id || USER_ID.anon
})
}
// createMany is the set difference of the new - old
// deleteMany is the set difference of the old - new
// updateMany is the intersection of the old and new
const difference = (a = [], b = [], key = 'userId') => a.filter(x => !b.find(y => y[key] === x[key]))
const intersectionMerge = (a = [], b = [], key) => a.filter(x => b.find(y => y.userId === x.userId))
.map(x => ({ [key]: x[key], ...b.find(y => y.userId === x.userId) }))
const mentions = await getMentions(args, context)
const itemMentions = await getItemMentions(args, context)
const itemUploads = uploadIds.map(id => ({ uploadId: id }))
await tx.upload.updateMany({
where: { id: { in: uploadIds } },
data: { paid: true }
})
const item = await tx.item.update({
where: { id: parseInt(id) },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } }
},
data: {
...data,
boost,
pollOptions: {
createMany: {
data: pollOptions?.map(option => ({ option }))
}
},
itemUploads: {
create: difference(itemUploads, old.itemUploads, 'uploadId').map(({ uploadId }) => ({ uploadId })),
deleteMany: {
uploadId: {
in: difference(old.itemUploads, itemUploads, 'uploadId').map(({ uploadId }) => uploadId)
}
}
},
itemActs: {
createMany: {
data: itemActs
}
},
itemForwards: {
deleteMany: {
userId: {
in: difference(old.itemForwards, itemForwards).map(({ userId }) => userId)
}
},
createMany: {
data: difference(itemForwards, old.itemForwards)
},
update: intersectionMerge(old.itemForwards, itemForwards, 'id').map(({ id, ...data }) => ({
where: { id },
data
}))
},
threadSubscriptions: {
deleteMany: {
userId: {
in: difference(old.itemForwards, itemForwards).map(({ userId }) => userId)
}
},
createMany: {
data: difference(itemForwards, old.itemForwards).map(({ userId }) => ({ userId }))
}
},
mentions: {
deleteMany: {
userId: {
in: difference(old.mentions, mentions).map(({ userId }) => userId)
}
},
createMany: {
data: difference(mentions, old.mentions)
}
},
itemReferrers: {
deleteMany: {
refereeId: {
in: difference(old.itemReferrers, itemMentions, 'refereeId').map(({ refereeId }) => refereeId)
}
},
create: difference(itemMentions, old.itemReferrers, 'refereeId')
}
}
})
await tx.$executeRaw`INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true, now() + interval '5 seconds')`
await performBotBehavior(args, context)
// TODO: referals for boost
// notify all the mentions if the mention is new
for (const { userId, createdAt } of item.mentions) {
if (item.updatedAt.getTime() === createdAt.getTime()) continue
notifyMention({ models, item, userId }).catch(console.error)
}
for (const { refereeItem, createdAt } of item.itemReferrers) {
if (item.updatedAt.getTime() === createdAt.getTime()) continue
notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error)
}
// ltree is unsupported in Prisma, so we have to query it manually (FUCK!)
return (await tx.$queryRaw`
SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt"
FROM "Item" WHERE id = ${parseInt(id)}::INTEGER`
)[0]
}
export async function describe ({ id, parentId }, context) {
return `SN: update ${parentId ? `reply to #${parentId}` : 'post'}`
}

View File

@ -0,0 +1,88 @@
import { USER_ID } from '@/lib/constants'
import { deleteReminders, getDeleteAt, getRemindAt } from '@/lib/item'
import { parseInternalLinks } from '@/lib/url'
export async function getMentions ({ text }, { me, models }) {
const mentionPattern = /\B@[\w_]+/gi
const names = text.match(mentionPattern)?.map(m => m.slice(1))
if (names?.length > 0) {
const users = await models.user.findMany({
where: {
name: {
in: names
},
id: {
not: me?.id || USER_ID.anon
}
}
})
return users.map(user => ({ userId: user.id }))
}
return []
}
export const getItemMentions = async ({ text }, { me, models }) => {
const linkPattern = new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/\\d+[a-zA-Z0-9/?=]*`, 'gi')
const refs = text.match(linkPattern)?.map(m => {
try {
const { itemId, commentId } = parseInternalLinks(m)
return Number(commentId || itemId)
} catch (err) {
return null
}
}).filter(r => !!r)
if (refs?.length > 0) {
const referee = await models.item.findMany({
where: {
id: { in: refs }
}
})
return referee.map(r => ({ refereeId: r.id }))
}
return []
}
export async function performBotBehavior ({ text, id }, { me, tx }) {
// delete any existing deleteItem or reminder jobs for this item
const userId = me?.id || USER_ID.anon
id = Number(id)
await tx.$queryRaw`
DELETE FROM pgboss.job
WHERE name = 'deleteItem'
AND data->>'id' = ${id}::TEXT
AND state <> 'completed'`
await deleteReminders({ id, userId, models: tx })
if (text) {
const deleteAt = getDeleteAt(text)
if (deleteAt) {
await tx.$queryRaw`
INSERT INTO pgboss.job (name, data, startafter, expirein)
VALUES (
'deleteItem',
jsonb_build_object('id', ${id}::INTEGER),
${deleteAt}::TIMESTAMP WITH TIME ZONE,
${deleteAt}::TIMESTAMP WITH TIME ZONE - now() + interval '1 minute')`
}
const remindAt = getRemindAt(text)
if (remindAt) {
await tx.$queryRaw`
INSERT INTO pgboss.job (name, data, startafter, expirein)
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')`
await tx.reminder.create({
data: {
userId,
itemId: Number(id),
remindAt
}
})
}
}
}

View File

@ -0,0 +1,65 @@
import { satsToMsats } from '@/lib/format'
export const anonable = false
export const supportsPessimism = true
export const supportsOptimism = true
export async function getCost ({ id }, { me, models }) {
const pollOption = await models.pollOption.findUnique({
where: { id: parseInt(id) },
include: { item: true }
})
return satsToMsats(pollOption.item.pollCost)
}
export async function perform ({ invoiceId, id }, { me, cost, tx }) {
const pollOption = await tx.pollOption.findUnique({
where: { id: parseInt(id) }
})
const itemId = parseInt(pollOption.itemId)
let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
// store a reference to the item in the invoice
await tx.invoice.update({
where: { id: invoiceId },
data: { actionId: itemId }
})
}
// the unique index on userId, itemId will prevent double voting
await tx.itemAct.create({ data: { msats: cost, itemId, userId: me.id, act: 'POLL', ...invoiceData } })
await tx.pollBlindVote.create({ data: { userId: me.id, itemId, ...invoiceData } })
await tx.pollVote.create({ data: { pollOptionId: pollOption.id, itemId, ...invoiceData } })
return { id }
}
export async function retry ({ invoiceId, newInvoiceId }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
await tx.pollBlindVote.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
await tx.pollVote.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
const { pollOptionId } = await tx.pollVote.findFirst({ where: { invoiceId: newInvoiceId } })
return { id: pollOptionId }
}
export async function onPaid ({ invoice }, { tx }) {
if (!invoice) return
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
await tx.pollBlindVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
// anonymize the vote
await tx.pollVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceId: null, invoiceActionState: null } })
}
export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
await tx.pollBlindVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
await tx.pollVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}
export async function describe ({ id }, { actionId }) {
return `SN: vote on poll #${id ?? actionId}`
}

View File

@ -0,0 +1,69 @@
import { TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory'
export const anonable = false
export const supportsPessimism = true
export const supportsOptimism = false
export async function getCost ({ name }, { models }) {
const sub = await models.sub.findUnique({
where: {
name
}
})
return satsToMsats(TERRITORY_PERIOD_COST(sub.billingType))
}
export async function perform ({ name }, { cost, tx }) {
const sub = await tx.sub.findUnique({
where: {
name
}
})
if (sub.billingType === 'ONCE') {
throw new Error('Cannot bill a ONCE territory')
}
let billedLastAt = sub.billPaidUntil
let billingCost = sub.billingCost
// if the sub is archived, they are paying to reactivate it
if (sub.status === 'STOPPED') {
// get non-grandfathered cost and reset their billing to start now
billedLastAt = new Date()
billingCost = TERRITORY_PERIOD_COST(sub.billingType)
}
const billPaidUntil = nextBilling(billedLastAt, sub.billingType)
return await tx.sub.update({
// optimistic concurrency control
// make sure the sub hasn't changed since we fetched it
where: {
...sub,
postTypes: {
equals: sub.postTypes
}
},
data: {
billedLastAt,
billPaidUntil,
billingCost,
status: 'ACTIVE',
SubAct: {
create: {
msats: cost,
type: 'BILLING',
userId: sub.userId
}
}
}
})
}
export async function describe ({ name }) {
return `SN: billing for territory ${name}`
}

View File

@ -0,0 +1,44 @@
import { TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory'
export const anonable = false
export const supportsPessimism = true
export const supportsOptimism = false
export async function getCost ({ billingType }) {
return satsToMsats(TERRITORY_PERIOD_COST(billingType))
}
export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
const { billingType } = data
const billingCost = TERRITORY_PERIOD_COST(billingType)
const billedLastAt = new Date()
const billPaidUntil = nextBilling(billedLastAt, billingType)
return await tx.sub.create({
data: {
...data,
billedLastAt,
billPaidUntil,
billingCost,
rankingType: 'WOT',
userId: me.id,
SubAct: {
create: {
msats: cost,
type: 'BILLING',
userId: me.id
}
},
SubSubscription: {
create: {
userId: me.id
}
}
}
})
}
export async function describe ({ name }) {
return `SN: create territory ${name}`
}

View File

@ -0,0 +1,61 @@
import { TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory'
export const anonable = false
export const supportsPessimism = true
export const supportsOptimism = false
export async function getCost ({ billingType }) {
return satsToMsats(TERRITORY_PERIOD_COST(billingType))
}
export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
const sub = await tx.sub.findUnique({
where: {
name
}
})
data.billingCost = TERRITORY_PERIOD_COST(data.billingType)
// we never want to bill them again if they are changing to ONCE
if (data.billingType === 'ONCE') {
data.billPaidUntil = null
data.billingAutoRenew = false
}
data.billedLastAt = new Date()
data.billPaidUntil = nextBilling(data.billedLastAt, data.billingType)
data.status = 'ACTIVE'
data.userId = me.id
if (sub.userId !== me.id) {
await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } })
}
await tx.subAct.create({
data: {
userId: me.id,
subName: name,
msats: cost,
type: 'BILLING'
}
})
return await tx.sub.update({
data,
// optimistic concurrency control
// make sure none of the relevant fields have changed since we fetched the sub
where: {
...sub,
postTypes: {
equals: sub.postTypes
}
}
})
}
export async function describe ({ name }, context) {
return `SN: unarchive territory ${name}`
}

View File

@ -0,0 +1,79 @@
import { TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { proratedBillingCost } from '@/lib/territory'
import { datePivot } from '@/lib/time'
export const anonable = false
export const supportsPessimism = true
export const supportsOptimism = false
export async function getCost ({ oldName, billingType }, { models }) {
const oldSub = await models.sub.findUnique({
where: {
name: oldName
}
})
const cost = proratedBillingCost(oldSub, billingType)
if (!cost) {
return 0n
}
return satsToMsats(cost)
}
export async function perform ({ oldName, invoiceId, ...data }, { me, cost, tx }) {
const oldSub = await tx.sub.findUnique({
where: {
name: oldName
}
})
data.billingCost = TERRITORY_PERIOD_COST(data.billingType)
// we never want to bill them again if they are changing to ONCE
if (data.billingType === 'ONCE') {
data.billPaidUntil = null
data.billingAutoRenew = false
}
// if they are changing to YEARLY, bill them in a year
// if they are changing to MONTHLY from YEARLY, do nothing
if (oldSub.billingType === 'MONTHLY' && data.billingType === 'YEARLY') {
data.billPaidUntil = datePivot(new Date(oldSub.billedLastAt), { years: 1 })
}
// if this billing change makes their bill paid up, set them to active
if (data.billPaidUntil === null || data.billPaidUntil >= new Date()) {
data.status = 'ACTIVE'
}
if (cost > 0n) {
await tx.subAct.create({
data: {
userId: me.id,
subName: oldName,
msats: cost,
type: 'BILLING'
}
})
}
return await tx.sub.update({
data,
where: {
// optimistic concurrency control
// make sure none of the relevant fields have changed since we fetched the sub
...oldSub,
postTypes: {
equals: oldSub.postTypes
},
name: oldName,
userId: me.id
}
})
}
export async function describe ({ name }, context) {
return `SN: update territory billing ${name}`
}

151
api/paidAction/zap.js Normal file
View File

@ -0,0 +1,151 @@
import { USER_ID } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { notifyZapped } from '@/lib/webPush'
export const anonable = true
export const supportsPessimism = true
export const supportsOptimism = true
export async function getCost ({ sats }) {
return satsToMsats(sats)
}
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) {
const feeMsats = cost / BigInt(100)
const zapMsats = cost - feeMsats
itemId = parseInt(itemId)
let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
// store a reference to the item in the invoice
await tx.invoice.update({
where: { id: invoiceId },
data: { actionId: itemId }
})
}
const acts = await tx.itemAct.createManyAndReturn({
data: [
{ msats: feeMsats, itemId, userId: me?.id || USER_ID.anon, act: 'FEE', ...invoiceData },
{ msats: zapMsats, itemId, userId: me?.id || USER_ID.anon, act: 'TIP', ...invoiceData }
]
})
const [{ path }] = await tx.$queryRaw`
SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
return { id: itemId, sats, act: 'TIP', path, actIds: acts.map(act => act.id) }
}
export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
const [{ id, path }] = await tx.$queryRaw`
SELECT "Item".id, ltree2text(path) as path
FROM "Item"
JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId"
WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER`
return { id, sats: msatsToSats(cost), act: 'TIP', path }
}
export async function onPaid ({ invoice, actIds }, { models, tx }) {
let acts
if (invoice) {
await tx.itemAct.updateMany({
where: { invoiceId: invoice.id },
data: {
invoiceActionState: 'PAID'
}
})
acts = await tx.itemAct.findMany({ where: { invoiceId: invoice.id }, include: { item: true } })
actIds = acts.map(act => act.id)
} else if (actIds) {
acts = await tx.itemAct.findMany({ where: { id: { in: actIds } }, include: { item: true } })
} else {
throw new Error('No invoice or actIds')
}
const msats = acts.reduce((a, b) => a + BigInt(b.msats), BigInt(0))
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
), forward AS (
UPDATE users
SET msats = users.msats + forwardees.msats
FROM forwardees
WHERE users.id = forwardees."userId"
)
UPDATE users
SET msats = msats + ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT
WHERE id = ${itemAct.item.userId}::INTEGER`
// 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
await tx.$executeRaw`
WITH zapper AS (
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER
), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
ON CONFLICT ("itemId", "userId") DO UPDATE
SET "zapSats" = "ItemUserAgg"."zapSats" + ${sats}::INTEGER, updated_at = now()
RETURNING ("zapSats" = ${sats}::INTEGER)::INTEGER as first_vote,
LOG("zapSats" / GREATEST("zapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
)
UPDATE "Item"
SET
"weightedVotes" = "weightedVotes" + (zapper.trust * zap.log_sats),
upvotes = upvotes + zap.first_vote,
msats = "Item".msats + ${msats}::BIGINT,
"lastZapAt" = now()
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
// record potential bounty payment
// NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust
// we won't miss a zap that aggregates into a bounty payment, regardless of the order of updates
await tx.$executeRaw`
WITH bounty AS (
SELECT root.id, "ItemUserAgg"."zapSats" >= root.bounty AS paid, "ItemUserAgg"."itemId" AS target
FROM "ItemUserAgg"
JOIN "Item" ON "Item".id = "ItemUserAgg"."itemId"
LEFT JOIN "Item" root ON root.id = "Item"."rootId"
WHERE "ItemUserAgg"."userId" = ${itemAct.userId}::INTEGER
AND "ItemUserAgg"."itemId" = ${itemAct.itemId}::INTEGER
AND root."userId" = ${itemAct.userId}::INTEGER
AND root.bounty IS NOT NULL
)
UPDATE "Item"
SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL)
FROM bounty
WHERE "Item".id = bounty.id AND bounty.paid`
// update commentMsats on ancestors
await tx.$executeRaw`
WITH zapped AS (
SELECT * FROM "Item" WHERE id = ${itemAct.itemId}::INTEGER
)
UPDATE "Item"
SET "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT
FROM zapped
WHERE "Item".path @> zapped.path AND "Item".id <> zapped.id`
// TODO: referrals
notifyZapped({ models, id: itemAct.itemId }).catch(console.error)
}
export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}
export async function describe ({ id: itemId, sats }, { actionId, cost }) {
return `SN: zap ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
}

View File

@ -47,26 +47,6 @@ export function viewGroup (range, view) {
) u` ) u`
} }
export function subViewGroup (range) {
const unit = timeUnitForRange(range)
return `(
(SELECT *
FROM sub_stats_days
WHERE ${viewIntervalClause(range, 'sub_stats_days')})
UNION ALL
(SELECT *
FROM sub_stats_hours
WHERE ${viewIntervalClause(range, 'sub_stats_hours')}
${unit === 'hour' ? '' : 'AND "sub_stats_hours".t >= date_trunc(\'day\', timezone(\'America/Chicago\', now()))'})
UNION ALL
(SELECT * FROM
sub_stats(
date_trunc('hour', timezone('America/Chicago', now())),
date_trunc('hour', timezone('America/Chicago', now())), '1 hour'::INTERVAL, 'hour')
WHERE "sub_stats".t >= date_trunc('${unit}', timezone('America/Chicago', $1)))
)`
}
export default { export default {
Query: { Query: {
registrationGrowth: async (parent, { when, from, to }, { models }) => { registrationGrowth: async (parent, { when, from, to }, { models }) => {

View File

@ -10,8 +10,8 @@ export default {
} }
export function uploadIdsFromText (text, { models }) { export function uploadIdsFromText (text, { models }) {
if (!text) return null if (!text) return []
return [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) return [...new Set([...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])))]
} }
export async function imageFeesInfo (s3Keys, { models, me }) { export async function imageFeesInfo (s3Keys, { models, me }) {

View File

@ -19,6 +19,7 @@ import chainFee from './chainFee'
import image from './image' import image from './image'
import { GraphQLScalarType, Kind } from 'graphql' import { GraphQLScalarType, Kind } from 'graphql'
import { createIntScalar } from 'graphql-scalar' import { createIntScalar } from 'graphql-scalar'
import paidAction from './paidAction'
const date = new GraphQLScalarType({ const date = new GraphQLScalarType({
name: 'Date', name: 'Date',
@ -54,4 +55,5 @@ const limit = createIntScalar({
}) })
export default [user, item, message, wallet, lnurl, notifications, invite, sub, export default [user, item, message, wallet, lnurl, notifications, invite, sub,
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee, image, { JSONObject }, { Date: date }, { Limit: limit }] upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
image, { JSONObject }, { Date: date }, { Limit: limit }, paidAction]

View File

@ -1,6 +1,5 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { ensureProtocol, parseInternalLinks, removeTracking, stripTrailingSlash } from '@/lib/url' import { ensureProtocol, removeTracking, stripTrailingSlash } from '@/lib/url'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import { ruleSet as publicationDateRuleSet } from '@/lib/timedate-scraper' import { ruleSet as publicationDateRuleSet } from '@/lib/timedate-scraper'
@ -8,19 +7,19 @@ import domino from 'domino'
import { import {
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD, ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST, USER_ID, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_USER_IDS ITEM_ALLOW_EDITS, GLOBAL_SEED, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_USER_IDS
} from '@/lib/constants' } from '@/lib/constants'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts' import { parse } from 'tldts'
import uu from 'url-unshort' import uu from 'url-unshort'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate' import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate'
import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention, notifyItemMention } from '@/lib/webPush' import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand, getReminderCommand, hasReminderCommand } from '@/lib/item'
import { datePivot, whenRange } from '@/lib/time' import { datePivot, whenRange } from '@/lib/time'
import { imageFeesInfo, uploadIdsFromText } from './image' import { uploadIdsFromText } from './image'
import assertGofacYourself from './ofac' import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
import performPaidAction from '../paidAction'
function commentsOrderByClause (me, models, sort) { function commentsOrderByClause (me, models, sort) {
if (sort === 'recent') { if (sort === 'recent') {
@ -44,13 +43,15 @@ function commentsOrderByClause (me, models, sort) {
async function comments (me, models, id, sort) { async function comments (me, models, id, sort) {
const orderBy = commentsOrderByClause(me, models, sort) const orderBy = commentsOrderByClause(me, models, sort)
const filter = '' // empty filter as we filter clientside now
if (me) { if (me) {
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id}) `
const [{ item_comments_zaprank_with_me: comments }] = await models.$queryRawUnsafe( const [{ item_comments_zaprank_with_me: comments }] = await models.$queryRawUnsafe(
'SELECT item_comments_zaprank_with_me($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5, $6)', Number(id), GLOBAL_SEED, Number(me.id), COMMENT_DEPTH_LIMIT, filter, orderBy) 'SELECT item_comments_zaprank_with_me($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5, $6)',
Number(id), GLOBAL_SEED, Number(me.id), COMMENT_DEPTH_LIMIT, filter, orderBy)
return comments return comments
} }
const filter = ' AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = \'PAID\') '
const [{ item_comments: comments }] = await models.$queryRawUnsafe( const [{ item_comments: comments }] = await models.$queryRawUnsafe(
'SELECT item_comments($1::INTEGER, $2::INTEGER, $3, $4)', Number(id), COMMENT_DEPTH_LIMIT, filter, orderBy) 'SELECT item_comments($1::INTEGER, $2::INTEGER, $3, $4)', Number(id), COMMENT_DEPTH_LIMIT, filter, orderBy)
return comments return comments
@ -63,7 +64,10 @@ export async function getItem (parent, { id }, { me, models }) {
query: ` query: `
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
WHERE id = $1` ${whereClause(
'"Item".id = $1',
activeOrMine(me)
)}`
}, Number(id)) }, Number(id))
return item return item
} }
@ -115,7 +119,7 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
} else { } else {
return await models.$queryRawUnsafe(` return await models.$queryRawUnsafe(`
SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user, SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user,
COALESCE("ItemAct"."meMsats", 0) as "meMsats", COALESCE("ItemAct"."meMsats", 0) as "meMsats", COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats",
COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark", 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", "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) to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL)
@ -132,8 +136,10 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id} LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}
LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id} LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id}
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT "itemId", sum("ItemAct".msats) FILTER (WHERE act = 'FEE' OR act = 'TIP') AS "meMsats", SELECT "itemId",
sum("ItemAct".msats) FILTER (WHERE act = 'DONT_LIKE_THIS') AS "meDontLikeMsats" 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 act = 'DONT_LIKE_THIS') AS "meDontLikeMsats"
FROM "ItemAct" FROM "ItemAct"
WHERE "ItemAct"."userId" = ${me.id} WHERE "ItemAct"."userId" = ${me.id}
AND "ItemAct"."itemId" = "Item".id AND "ItemAct"."itemId" = "Item".id
@ -180,8 +186,11 @@ function whenClause (when, table) {
return `"${table}".created_at <= $2 and "${table}".created_at >= $1` return `"${table}".created_at <= $2 and "${table}".created_at >= $1`
} }
const activeOrMine = (me) => { export const activeOrMine = (me) => {
return me ? `("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id})` : '"Item".status <> \'STOPPED\'' return me
? [`("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id})`,
`("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id})`]
: ['("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = \'PAID\')', '"Item".status <> \'STOPPED\'']
} }
export const muteClause = me => export const muteClause = me =>
@ -432,6 +441,7 @@ export default {
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL', '"Item"."parentId" IS NULL',
'"Item".bio = false', '"Item".bio = false',
activeOrMine(me),
subClause(sub, 3, 'Item', me, showNsfw), subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me))} muteClause(me))}
ORDER BY rank DESC ORDER BY rank DESC
@ -457,6 +467,7 @@ export default {
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL', '"Item"."parentId" IS NULL',
'"Item".bio = false', '"Item".bio = false',
activeOrMine(me),
await filterClause(me, models, type))} await filterClause(me, models, type))}
ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC
OFFSET $1 OFFSET $1
@ -724,10 +735,6 @@ export default {
if (old.bio) { if (old.bio) {
throw new GraphQLError('cannot delete bio', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('cannot delete bio', { extensions: { code: 'BAD_INPUT' } })
} }
// clean up any pending reminders, if triggered on this item and haven't been executed
if (hasReminderCommand(old.text)) {
await deleteReminderAndJob({ me, item: old, models })
}
return await deleteItemByAuthor({ models, id, item: old }) return await deleteItemByAuthor({ models, id, item: old })
}, },
@ -790,7 +797,7 @@ export default {
item.maxBid ??= 0 item.maxBid ??= 0
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models }) return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
} else { } else {
return await createItem(parent, item, { me, models, lnd, hash, hmac }) return await createItem(parent, item, { me, models, lnd, hash, hmac })
} }
@ -799,10 +806,9 @@ export default {
await ssValidate(commentSchema, item) await ssValidate(commentSchema, item)
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models }) return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
} else { } else {
item = await createItem(parent, item, { me, models, lnd, hash, hmac }) item = await createItem(parent, item, { me, models, lnd, hash, hmac })
notifyItemParents({ item, me, models })
return item return item
} }
}, },
@ -823,12 +829,7 @@ export default {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
} }
await serialize( return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd, hash, hmac })
models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`, Number(id), Number(me.id)),
{ models, lnd, me, hash, hmac, verifyPayment: !!hash || !me }
)
return id
}, },
act: async (parent, { id, sats, act = 'TIP', idempotent, hash, hmac }, { me, models, lnd, headers }) => { act: async (parent, { id, sats, act = 'TIP', idempotent, hash, hmac }, { me, models, lnd, headers }) => {
assertApiKeyNotPermitted({ me }) assertApiKeyNotPermitted({ me })
@ -844,6 +845,10 @@ export default {
throw new GraphQLError('item is deleted', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('item is deleted', { extensions: { code: 'BAD_INPUT' } })
} }
if (item.invoiceActionState && item.invoiceActionState !== 'PAID') {
throw new GraphQLError('cannot act on unpaid item', { extensions: { code: 'BAD_INPUT' } })
}
// disallow self tips except anons // disallow self tips except anons
if (me) { if (me) {
if (Number(item.userId) === Number(me.id)) { if (Number(item.userId) === Number(me.id)) {
@ -859,35 +864,12 @@ export default {
} }
} }
if (me && idempotent) { if (act === 'TIP') {
await serialize( return await performPaidAction('ZAP', { id, sats }, { me, models, lnd, hash, hmac })
models.$queryRaw` } else if (act === 'DONT_LIKE_THIS') {
SELECT return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd, hash, hmac })
item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, ${act}::"ItemActType",
(SELECT ${Number(sats)}::INTEGER - COALESCE(sum(msats) / 1000, 0)
FROM "ItemAct"
WHERE act IN ('TIP', 'FEE')
AND "itemId" = ${Number(id)}::INTEGER
AND "userId" = ${me.id}::INTEGER)::INTEGER)`,
{ models, lnd, hash, hmac, verifyPayment: !!hash }
)
} else { } else {
await serialize( throw new GraphQLError('unknown act', { extensions: { code: 'BAD_INPUT' } })
models.$queryRaw`
SELECT
item_act(${Number(id)}::INTEGER,
${me?.id || USER_ID.anon}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`,
{ models, lnd, me, hash, hmac, fee: sats, verifyPayment: !!hash || !me }
)
}
notifyZapped({ models, id })
return {
id,
sats,
act,
path: item.path
} }
}, },
toggleOutlaw: async (parent, { id }, { me, models }) => { toggleOutlaw: async (parent, { id }, { me, models }) => {
@ -942,9 +924,20 @@ export default {
return result return result
} }
}, },
ItemAct: {
invoice: async (itemAct, args, { models }) => {
if (itemAct.invoiceId) {
return {
id: itemAct.invoiceId,
actionState: itemAct.invoiceActionState
}
}
return null
}
},
Item: { Item: {
sats: async (item, args, { models }) => { sats: async (item, args, { models }) => {
return msatsToSats(item.msats) return msatsToSats(BigInt(item.msats) + BigInt(item.mePendingMsats || 0))
}, },
commentSats: async (item, args, { models }) => { commentSats: async (item, args, { models }) => {
return msatsToSats(item.commentMsats) return msatsToSats(item.commentMsats)
@ -1004,7 +997,10 @@ export default {
} }
const options = await models.$queryRaw` const options = await models.$queryRaw`
SELECT "PollOption".id, option, count("PollVote".id)::INTEGER as count SELECT "PollOption".id, option,
(count("PollVote".id)
FILTER(WHERE "PollVote"."invoiceActionState" IS NULL
OR "PollVote"."invoiceActionState" = 'PAID'))::INTEGER as count
FROM "PollOption" FROM "PollOption"
LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id
WHERE "PollOption"."itemId" = ${item.id} WHERE "PollOption"."itemId" = ${item.id}
@ -1012,16 +1008,22 @@ export default {
ORDER BY "PollOption".id ASC ORDER BY "PollOption".id ASC
` `
const meVoted = await models.pollBlindVote.findFirst({
where: {
userId: me?.id,
itemId: item.id
}
})
const poll = {} const poll = {}
if (me) {
const meVoted = await models.pollBlindVote.findFirst({
where: {
userId: me.id,
itemId: item.id
}
})
poll.meVoted = !!meVoted
poll.meInvoiceId = meVoted?.invoiceId
poll.meInvoiceActionState = meVoted?.invoiceActionState
} else {
poll.meVoted = false
}
poll.options = options poll.options = options
poll.meVoted = !!meVoted
poll.count = options.reduce((t, o) => t + o.count, 0) poll.count = options.reduce((t, o) => t + o.count, 0)
return poll return poll
@ -1064,6 +1066,9 @@ export default {
where: { where: {
itemId: Number(item.id), itemId: Number(item.id),
userId: me.id, userId: me.id,
invoiceActionState: {
not: 'FAILED'
},
OR: [ OR: [
{ {
act: 'TIP' act: 'TIP'
@ -1078,8 +1083,8 @@ export default {
return (msats && msatsToSats(msats)) || 0 return (msats && msatsToSats(msats)) || 0
}, },
meDontLikeSats: async (item, args, { me, models }) => { meDontLikeSats: async (item, args, { me, models }) => {
if (!me) return false if (!me) return 0
if (typeof item.meMsats !== 'undefined') { if (typeof item.meDontLikeMsats !== 'undefined') {
return msatsToSats(item.meDontLikeMsats) return msatsToSats(item.meDontLikeMsats)
} }
@ -1090,7 +1095,10 @@ export default {
where: { where: {
itemId: Number(item.id), itemId: Number(item.id),
userId: me.id, userId: me.id,
act: 'DONT_LIKE_THIS' act: 'DONT_LIKE_THIS',
invoiceActionState: {
not: 'FAILED'
}
} }
}) })
@ -1149,6 +1157,17 @@ export default {
} }
return await getItem(item, { id: item.rootId }, { me, models }) return await getItem(item, { id: item.rootId }, { me, models })
}, },
invoice: async (item, args, { models }) => {
if (item.invoiceId) {
return {
id: item.invoiceId,
actionState: item.invoiceActionState,
confirmedAt: item.invoicePaidAtUTC ?? item.invoicePaidAt
}
}
return null
},
parent: async (item, args, { models }) => { parent: async (item, args, { models }) => {
if (!item.parentId) { if (!item.parentId) {
return null return null
@ -1168,7 +1187,11 @@ export default {
// Only query for deleteScheduledAt for your own items to keep DB queries minimized // Only query for deleteScheduledAt for your own items to keep DB queries minimized
return null return null
} }
const deleteJobs = await models.$queryRawUnsafe(`SELECT startafter FROM pgboss.job WHERE name = 'deleteItem' AND data->>'id' = '${item.id}'`) const deleteJobs = await models.$queryRaw`
SELECT startafter
FROM pgboss.job
WHERE name = 'deleteItem' AND data->>'id' = ${item.id}::TEXT
AND state = 'created'`
return deleteJobs[0]?.startafter ?? null return deleteJobs[0]?.startafter ?? null
}, },
reminderScheduledAt: async (item, args, { me, models }) => { reminderScheduledAt: async (item, args, { me, models }) => {
@ -1178,115 +1201,30 @@ export default {
// don't support reminders for ANON // don't support reminders for ANON
return null return null
} }
const reminderJobs = await models.$queryRawUnsafe(`SELECT startafter FROM pgboss.job WHERE name = 'reminder' AND data->>'itemId' = '${item.id}' AND data->>'userId' = '${meId}'`) const reminderJobs = await models.$queryRaw`
SELECT startafter
FROM pgboss.job
WHERE name = 'reminder'
AND data->>'itemId' = ${item.id}::TEXT
AND data->>'userId' = ${meId}::TEXT
AND state = 'created'`
return reminderJobs[0]?.startafter ?? null return reminderJobs[0]?.startafter ?? null
} }
} }
} }
const namePattern = /\B@[\w_]+/gi export const updateItem = async (parent, { sub: subName, forward, ...item }, { me, models, lnd, hash, hmac }) => {
const refPattern = new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/\\d+[a-zA-Z0-9/?=]*`, 'gi')
export const createMentions = async (item, models) => {
// if we miss a mention, in the rare circumstance there's some kind of
// failure, it's not a big deal so we don't do it transactionally
// ideally, we probably would
if (!item.text) {
return
}
// user mentions
try {
await createUserMentions(item, models)
} catch (e) {
console.error('user mention failure', e)
}
// item mentions
try {
await createItemMentions(item, models)
} catch (e) {
console.error('item mention failure', e)
}
}
const createUserMentions = async (item, models) => {
const mentions = item.text.match(namePattern)?.map(m => m.slice(1))
if (!mentions || mentions.length === 0) return
const users = await models.user.findMany({
where: {
name: { in: mentions },
// Don't create mentions when mentioning yourself
id: { not: item.userId }
}
})
users.forEach(async user => {
const data = {
itemId: item.id,
userId: user.id
}
const mention = await models.mention.upsert({
where: {
itemId_userId: data
},
update: data,
create: data
})
// only send if mention is new to avoid duplicates
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
notifyMention({ models, userId: user.id, item })
}
})
}
const createItemMentions = async (item, models) => {
const refs = item.text.match(refPattern)?.map(m => {
try {
const { itemId, commentId } = parseInternalLinks(m)
return Number(commentId || itemId)
} catch (err) {
return null
}
}).filter(r => !!r)
if (!refs || refs.length === 0) return
const referee = await models.item.findMany({
where: {
id: { in: refs },
// Don't create mentions for your own items
userId: { not: item.userId }
}
})
referee.forEach(async r => {
const data = {
referrerId: item.id,
refereeId: r.id
}
const mention = await models.itemMention.upsert({
where: {
referrerId_refereeId: data
},
update: data,
create: data
})
// only send if mention is new to avoid duplicates
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
notifyItemMention({ models, referrerItem: item, refereeItem: r })
}
})
}
export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, models, lnd, hash, hmac }) => {
// update iff this item belongs to me // update iff this item belongs to me
const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } }) const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } })
if (old.deletedAt) {
throw new GraphQLError('item is deleted', { extensions: { code: 'BAD_INPUT' } })
}
if (old.invoiceActionState && old.invoiceActionState !== 'PAID') {
throw new GraphQLError('cannot edit unpaid item', { extensions: { code: 'BAD_INPUT' } })
}
// author can always edit their own item // author can always edit their own item
const mid = Number(me?.id) const mid = Number(me?.id)
const isMine = Number(old.userId) === mid const isMine = Number(old.userId) === mid
@ -1318,9 +1256,9 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
// prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes // prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes
const myBio = user.bioId === old.id const myBio = user.bioId === old.id
const timer = Date.now() < new Date(old.createdAt).getTime() + 10 * 60_000 const timer = Date.now() < new Date(old.invoicePaidAt ?? old.createdAt).getTime() + 10 * 60_000
if (!allowEdit && !myBio && !timer) { if (!allowEdit && !myBio && !timer && !isJob(item)) {
throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } })
} }
@ -1328,159 +1266,51 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
item.url = ensureProtocol(item.url) item.url = ensureProtocol(item.url)
item.url = removeTracking(item.url) item.url = removeTracking(item.url)
} }
// only update item with the boost delta ... this is a bit of hack given the way
// boost used to work // prevent editing a bio like a regular item
if (item.boost > 0 && old.boost > 0) { if (old.bio) {
// only update the boost if it is higher than the old boost item = { id: Number(item.id), text: item.text, title: `@${user.name}'s bio`, userId: me.id }
if (item.boost > old.boost) { } else {
item.boost = item.boost - old.boost item = { subName, userId: me.id, ...item }
} else { item.forwardUsers = await getForwardUsers(models, forward)
delete item.boost
}
} }
item.uploadIds = uploadIdsFromText(item.text, { models })
item = { subName, userId: old.userId, ...item } const resultItem = await performPaidAction('ITEM_UPDATE', item, { models, me, lnd, hash, hmac })
const fwdUsers = await getForwardUsers(models, forward)
const uploadIds = uploadIdsFromText(item.text, { models }) resultItem.comments = []
const { totalFees: imgFees } = await imageFeesInfo(uploadIds, { models, me }); return resultItem
([item] = await serialize(
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
{ models, lnd, me, hash, hmac, fee: imgFees, verifyPayment: !!hash || !me }
))
await createMentions(item, models)
if (hasDeleteCommand(old.text)) {
// delete any deletion jobs that were created from a prior version of the item
await clearDeletionJobs(item, models)
}
await enqueueDeletionJob(item, models)
if (hasReminderCommand(old.text)) {
// delete any reminder jobs that were created from a prior version of the item
await deleteReminderAndJob({ me, item, models })
}
await createReminderAndJob({ me, item, models })
item.comments = []
return item
} }
export const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, hash, hmac }) => { export const createItem = async (parent, { forward, ...item }, { me, models, lnd, hash, hmac }) => {
const spamInterval = me ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL
// rename to match column name // rename to match column name
item.subName = item.sub item.subName = item.sub
delete item.sub delete item.sub
item.userId = me ? Number(me.id) : USER_ID.anon item.userId = me ? Number(me.id) : USER_ID.anon
const fwdUsers = await getForwardUsers(models, forward) item.forwardUsers = await getForwardUsers(models, forward)
item.uploadIds = uploadIdsFromText(item.text, { models })
if (item.url && !isJob(item)) { if (item.url && !isJob(item)) {
item.url = ensureProtocol(item.url) item.url = ensureProtocol(item.url)
item.url = removeTracking(item.url) item.url = removeTracking(item.url)
} }
if (item.parentId) {
const parent = await models.item.findUnique({ where: { id: parseInt(item.parentId) } })
if (parent.invoiceActionState && parent.invoiceActionState !== 'PAID') {
throw new GraphQLError('cannot comment on unpaid item', { extensions: { code: 'BAD_INPUT' } })
}
}
// mark item as created with API key // mark item as created with API key
item.apiKey = me?.apiKey item.apiKey = me?.apiKey
const uploadIds = uploadIdsFromText(item.text, { models }) const resultItem = await performPaidAction('ITEM_CREATE', item, { models, me, lnd, hash, hmac })
const { totalFees: imgFees } = await imageFeesInfo(uploadIds, { models, me })
let fee = 0 resultItem.comments = []
if (!me) { return resultItem
if (item.parentId) {
fee = ANON_FEE_MULTIPLIER
} else {
const sub = await models.sub.findUnique({ where: { name: item.subName } })
fee = sub.baseCost * ANON_FEE_MULTIPLIER + (item.boost || 0)
}
}
fee += imgFees;
([item] = await serialize(
models.$queryRawUnsafe(
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
{ models, lnd, me, hash, hmac, fee, verifyPayment: !!hash || !me }
))
await createMentions(item, models)
await enqueueDeletionJob(item, models)
await createReminderAndJob({ me, item, models })
notifyUserSubscribers({ models, item })
notifyTerritorySubscribers({ models, item })
item.comments = []
return item
}
const clearDeletionJobs = async (item, models) => {
await models.$queryRawUnsafe(`DELETE FROM pgboss.job WHERE name = 'deleteItem' AND data->>'id' = '${item.id}';`)
}
const enqueueDeletionJob = async (item, models) => {
const deleteCommand = getDeleteCommand(item.text)
if (deleteCommand) {
await models.$queryRawUnsafe(`
INSERT INTO pgboss.job (name, data, startafter, expirein)
VALUES (
'deleteItem',
jsonb_build_object('id', ${item.id}),
now() + interval '${deleteCommand.number} ${deleteCommand.unit}s',
interval '${deleteCommand.number} ${deleteCommand.unit}s' + interval '1 minute')`)
}
}
const deleteReminderAndJob = async ({ me, item, models }) => {
if (me?.id && me.id !== USER_ID.anon) {
await models.$transaction([
models.$queryRawUnsafe(`
DELETE FROM pgboss.job
WHERE name = 'reminder'
AND data->>'itemId' = '${item.id}'
AND data->>'userId' = '${me.id}'
AND state <> 'completed'`),
models.reminder.deleteMany({
where: {
itemId: Number(item.id),
userId: Number(me.id),
remindAt: {
gt: new Date()
}
}
})])
}
}
const createReminderAndJob = async ({ me, item, models }) => {
// disallow anon to use reminder
if (!me || me.id === USER_ID.anon) {
return
}
const reminderCommand = getReminderCommand(item.text)
if (reminderCommand) {
await models.$transaction([
models.$queryRawUnsafe(`
INSERT INTO pgboss.job (name, data, startafter, expirein)
VALUES (
'reminder',
jsonb_build_object('itemId', ${item.id}, 'userId', ${me.id}),
now() + interval '${reminderCommand.number} ${reminderCommand.unit}s',
interval '${reminderCommand.number} ${reminderCommand.unit}s' + interval '1 minute')`),
// use a raw query instead of the model to reuse the built-in `now + interval` support instead of doing it via JS
models.$queryRawUnsafe(`
INSERT INTO "Reminder" ("userId", "itemId", "remindAt")
VALUES (${me.id}, ${item.id}, now() + interval '${reminderCommand.number} ${reminderCommand.unit}s')`)
])
}
} }
const getForwardUsers = async (models, forward) => { const getForwardUsers = async (models, forward) => {
@ -1501,15 +1331,8 @@ const getForwardUsers = async (models, forward) => {
// we have to do our own query because ltree is unsupported // we have to do our own query because ltree is unsupported
export const SELECT = export const SELECT =
`SELECT "Item".id, "Item".created_at, "Item".created_at as "createdAt", "Item".updated_at, `SELECT "Item".*, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt",
"Item".updated_at as "updatedAt", "Item".title, "Item".text, "Item".url, "Item"."bounty", ltree2text("Item"."path") AS "path"`
"Item"."noteId", "Item"."userId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
"Item"."rootId", "Item".upvotes, "Item".company, "Item".location, "Item".remote, "Item"."deletedAt",
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats,
"Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",
"Item"."weightedDownVotes", "Item".freebie, "Item".bio, "Item"."otsHash", "Item"."bountyPaidTo",
ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls", "Item".outlawed,
"Item"."pollExpiresAt", "Item"."apiKey"`
function topOrderByWeightedSats (me, models) { function topOrderByWeightedSats (me, models) {
return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC` return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC`

View File

@ -1,6 +1,6 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor'
import { getItem, filterClause, whereClause, muteClause } from './item' import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item'
import { getInvoice, getWithdrawl } from './wallet' import { getInvoice, getWithdrawl } from './wallet'
import { pushSubscriptionSchema, ssValidate } from '@/lib/validate' import { pushSubscriptionSchema, ssValidate } from '@/lib/validate'
import { replyToSubscription } from '@/lib/webPush' import { replyToSubscription } from '@/lib/webPush'
@ -167,7 +167,8 @@ export default {
${whereClause( ${whereClause(
'"Item".created_at < $2', '"Item".created_at < $2',
await filterClause(me, models), await filterClause(me, models),
muteClause(me))} muteClause(me),
activeOrMine(me))}
ORDER BY id ASC, CASE ORDER BY id ASC, CASE
WHEN type = 'Mention' THEN 1 WHEN type = 'Mention' THEN 1
WHEN type = 'Reply' THEN 2 WHEN type = 'Reply' THEN 2
@ -233,6 +234,7 @@ export default {
WHERE "Invoice"."userId" = $1 WHERE "Invoice"."userId" = $1
AND "confirmedAt" IS NOT NULL AND "confirmedAt" IS NOT NULL
AND "isHeld" IS NULL AND "isHeld" IS NULL
AND "actionState" IS NULL
AND created_at < $2 AND created_at < $2
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT})` LIMIT ${LIMIT})`
@ -330,6 +332,22 @@ export default {
LIMIT ${LIMIT})` LIMIT ${LIMIT})`
) )
queries.push(
`(SELECT "Invoice".id::text, "Invoice"."updated_at" AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type
FROM "Invoice"
WHERE "Invoice"."userId" = $1
AND "Invoice"."updated_at" < $2
AND "Invoice"."actionState" = 'FAILED'
AND (
"Invoice"."actionType" = 'ITEM_CREATE' OR
"Invoice"."actionType" = 'ZAP' OR
"Invoice"."actionType" = 'DOWN_ZAP' OR
"Invoice"."actionType" = 'POLL_VOTE'
)
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
const notifications = await models.$queryRawUnsafe( const notifications = await models.$queryRawUnsafe(
`SELECT id, "sortTime", "earnedSats", type, `SELECT id, "sortTime", "earnedSats", type,
"sortTime" AS "minSortTime" "sortTime" AS "minSortTime"
@ -479,6 +497,9 @@ export default {
InvoicePaid: { InvoicePaid: {
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models }) invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
}, },
Invoicification: {
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
},
WithdrawlPaid: { WithdrawlPaid: {
withdrawl: async (n, args, { me, models }) => getWithdrawl(n, { id: n.id }, { me, models }) withdrawl: async (n, args, { me, models }) => getWithdrawl(n, { id: n.id }, { me, models })
}, },

View File

@ -0,0 +1,39 @@
import { retryPaidAction } from '../paidAction'
export default {
Mutation: {
retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => {
if (!me) {
throw new Error('You must be logged in')
}
const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me.id } })
if (!invoice) {
throw new Error('Invoice not found')
}
let type
if (invoice.actionType === 'ITEM_CREATE') {
type = 'ItemPaidAction'
} else if (invoice.actionType === 'ZAP') {
type = 'ItemActPaidAction'
} else if (invoice.actionType === 'POLL_VOTE') {
type = 'PollVotePaidAction'
} else if (invoice.actionType === 'DOWN_ZAP') {
type = 'ItemActPaidAction'
} else {
throw new Error('Unknown action type')
}
const result = await retryPaidAction(invoice.actionType, { invoiceId }, { models, me, lnd })
return {
...result,
type
}
}
},
PaidAction: {
__resolveType: obj => obj.type
}
}

View File

@ -1,9 +1,8 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { amountSchema, ssValidate } from '@/lib/validate' import { amountSchema, ssValidate } from '@/lib/validate'
import serialize from './serial'
import { USER_ID } from '@/lib/constants'
import { getItem } from './item' import { getItem } from './item'
import { topUsers } from './user' import { topUsers } from './user'
import performPaidAction from '../paidAction'
let rewardCache let rewardCache
@ -164,12 +163,7 @@ export default {
donateToRewards: async (parent, { sats, hash, hmac }, { me, models, lnd }) => { donateToRewards: async (parent, { sats, hash, hmac }, { me, models, lnd }) => {
await ssValidate(amountSchema, { amount: sats }) await ssValidate(amountSchema, { amount: sats })
await serialize( return await performPaidAction('DONATE', { sats }, { me, models, lnd, hash, hmac })
models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || USER_ID.anon}::INTEGER)`,
{ models, lnd, me, hash, hmac, fee: sats, verifyPayment: !!hash || !me }
)
return sats
} }
}, },
Reward: { Reward: {

View File

@ -1,13 +1,10 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { timingSafeEqual } from 'crypto'
import retry from 'async-retry' import retry from 'async-retry'
import Prisma from '@prisma/client' import Prisma from '@prisma/client'
import { settleHodlInvoice } from 'ln-service'
import { createHmac } from './wallet'
import { msatsToSats, numWithUnits } from '@/lib/format' import { msatsToSats, numWithUnits } from '@/lib/format'
import { BALANCE_LIMIT_MSATS } from '@/lib/constants' import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
export default async function serialize (trx, { models, lnd, me, hash, hmac, fee, verifyPayment: verify }) { export default async function serialize (trx, { models, lnd }) {
// wrap first argument in array if not array already // wrap first argument in array if not array already
const isArray = Array.isArray(trx) const isArray = Array.isArray(trx)
if (!isArray) trx = [trx] if (!isArray) trx = [trx]
@ -16,16 +13,7 @@ export default async function serialize (trx, { models, lnd, me, hash, hmac, fee
// we filter any falsy value out here // we filter any falsy value out here
trx = trx.filter(q => !!q) trx = trx.filter(q => !!q)
let invoice const results = await retry(async bail => {
if (verify) {
invoice = await verifyPayment(models, hash, hmac, fee)
trx = [
models.$executeRaw`SELECT confirm_invoice(${hash}, ${invoice.msatsReceived})`,
...trx
]
}
let results = await retry(async bail => {
try { try {
const [, ...results] = await models.$transaction( const [, ...results] = await models.$transaction(
[models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...trx], [models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...trx],
@ -83,59 +71,6 @@ export default async function serialize (trx, { models, lnd, me, hash, hmac, fee
retries: 10 retries: 10
}) })
if (hash) {
if (invoice?.isHeld) {
await settleHodlInvoice({ secret: invoice.preimage, lnd })
}
// remove first element since that is the confirmed invoice
results = results.slice(1)
}
// if first argument was not an array, unwrap the result // if first argument was not an array, unwrap the result
return isArray ? results : results[0] return isArray ? results : results[0]
} }
async function verifyPayment (models, hash, hmac, fee) {
if (!hash) {
throw new GraphQLError('hash required', { extensions: { code: 'BAD_INPUT' } })
}
if (!hmac) {
throw new GraphQLError('hmac required', { extensions: { code: 'BAD_INPUT' } })
}
const hmac2 = createHmac(hash)
if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } })
}
const invoice = await models.invoice.findUnique({
where: { hash },
include: {
user: true
}
})
if (!invoice) {
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
}
const expired = new Date(invoice.expiresAt) <= new Date()
if (expired) {
throw new GraphQLError('invoice expired', { extensions: { code: 'BAD_INPUT' } })
}
if (invoice.confirmedAt) {
throw new GraphQLError('invoice already used', { extensions: { code: 'BAD_INPUT' } })
}
if (invoice.cancelled) {
throw new GraphQLError('invoice was canceled', { extensions: { code: 'BAD_INPUT' } })
}
if (!invoice.msatsReceived) {
throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } })
}
if (fee && msatsToSats(invoice.msatsReceived) < fee) {
throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } })
}
return invoice
}

View File

@ -1,66 +1,10 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import serialize from './serial' import { whenRange } from '@/lib/time'
import { TERRITORY_COST_MONTHLY, TERRITORY_COST_ONCE, TERRITORY_COST_YEARLY, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { datePivot, whenRange } from '@/lib/time'
import { ssValidate, territorySchema } from '@/lib/validate' import { ssValidate, territorySchema } from '@/lib/validate'
import { nextBilling, proratedBillingCost } from '@/lib/territory'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { subViewGroup } from './growth' import { viewGroup } from './growth'
import { notifyTerritoryTransfer } from '@/lib/webPush' import { notifyTerritoryTransfer } from '@/lib/webPush'
export function paySubQueries (sub, models) { import performPaidAction from '../paidAction'
if (sub.billingType === 'ONCE') {
return []
}
// if in active or grace, consider we are billing them from where they are paid up
// and use grandfathered cost
let billedLastAt = sub.billPaidUntil
let billingCost = sub.billingCost
// if the sub is archived, they are paying to reactivate it
if (sub.status === 'STOPPED') {
// get non-grandfathered cost and reset their billing to start now
billedLastAt = new Date()
billingCost = TERRITORY_PERIOD_COST(sub.billingType)
}
const billPaidUntil = nextBilling(billedLastAt, sub.billingType)
const cost = BigInt(billingCost) * BigInt(1000)
return [
models.user.update({
where: {
id: sub.userId
},
data: {
msats: {
decrement: cost
}
}
}),
// update 'em
models.sub.update({
where: {
name: sub.name
},
data: {
billedLastAt,
billPaidUntil,
billingCost,
status: 'ACTIVE'
}
}),
// record 'em
models.subAct.create({
data: {
userId: sub.userId,
subName: sub.name,
msats: cost,
type: 'BILLING'
}
})
]
}
export async function getSub (parent, { name }, { models, me }) { export async function getSub (parent, { name }, { models, me }) {
if (!name) return null if (!name) return null
@ -150,8 +94,8 @@ export default {
COALESCE(floor(sum(msats_spent)/1000), 0) as spent, COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts, COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments COALESCE(sum(comments), 0) as ncomments
FROM ${subViewGroup(range)} ss FROM ${viewGroup(range, 'sub_stats')}
JOIN "Sub" on "Sub".name = ss.sub_name JOIN "Sub" on "Sub".name = u.sub_name
GROUP BY "Sub".name GROUP BY "Sub".name
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
OFFSET $3 OFFSET $3
@ -192,8 +136,8 @@ export default {
COALESCE(floor(sum(msats_spent)/1000), 0) as spent, COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts, COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments COALESCE(sum(comments), 0) as ncomments
FROM ${subViewGroup(range)} ss FROM ${viewGroup(range, 'sub_stats')}
JOIN "Sub" on "Sub".name = ss.sub_name JOIN "Sub" on "Sub".name = u.sub_name
WHERE "Sub"."userId" = $3 WHERE "Sub"."userId" = $3
AND "Sub".status = 'ACTIVE' AND "Sub".status = 'ACTIVE'
GROUP BY "Sub".name GROUP BY "Sub".name
@ -241,15 +185,7 @@ export default {
return sub return sub
} }
const queries = paySubQueries(sub, models) return await performPaidAction('TERRITORY_BILLING', { name }, { me, models, lnd, hash, hmac })
if (queries.length === 0) {
return sub
}
const results = await serialize(
queries,
{ models, lnd, me, hash, hmac, fee: sub.billingCost, verifyPayment: !!hash || !me })
return results[1]
}, },
toggleMuteSub: async (parent, { name }, { me, models }) => { toggleMuteSub: async (parent, { name }, { me, models }) => {
if (!me) { if (!me) {
@ -340,37 +276,7 @@ export default {
throw new GraphQLError('sub should not be archived', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('sub should not be archived', { extensions: { code: 'BAD_INPUT' } })
} }
const billingCost = TERRITORY_PERIOD_COST(data.billingType) return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd, hash, hmac })
const billPaidUntil = nextBilling(new Date(), data.billingType)
const cost = BigInt(1000) * BigInt(billingCost)
const newSub = { ...data, billPaidUntil, billingCost, userId: me.id, status: 'ACTIVE' }
const isTransfer = oldSub.userId !== me.id
await serialize([
models.user.update({
where: {
id: me.id
},
data: {
msats: {
decrement: cost
}
}
}),
models.subAct.create({
data: {
subName: name,
userId: me.id,
msats: cost,
type: 'BILLING'
}
}),
models.sub.update({ where: { name }, data: newSub }),
isTransfer && models.territoryTransfer.create({ data: { subName: name, oldUserId: oldSub.userId, newUserId: me.id } })
],
{ models, lnd, hash, me, hmac, fee: billingCost, verifyPayment: !!hash || !me })
if (isTransfer) notifyTerritoryTransfer({ models, sub: newSub, to: me })
} }
}, },
Sub: { Sub: {
@ -409,64 +315,8 @@ export default {
} }
async function createSub (parent, data, { me, models, lnd, hash, hmac }) { async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
const { billingType } = data
let billingCost = TERRITORY_COST_MONTHLY
const billedLastAt = new Date()
let billPaidUntil = datePivot(billedLastAt, { months: 1 })
if (billingType === 'ONCE') {
billingCost = TERRITORY_COST_ONCE
billPaidUntil = null
} else if (billingType === 'YEARLY') {
billingCost = TERRITORY_COST_YEARLY
billPaidUntil = datePivot(billedLastAt, { years: 1 })
}
const cost = BigInt(1000) * BigInt(billingCost)
try { try {
const results = await serialize([ return await performPaidAction('TERRITORY_CREATE', data, { me, models, lnd, hash, hmac })
// bill 'em
models.user.update({
where: {
id: me.id
},
data: {
msats: {
decrement: cost
}
}
}),
// create 'em
models.sub.create({
data: {
...data,
billedLastAt,
billPaidUntil,
billingCost,
rankingType: 'WOT',
userId: me.id
}
}),
// record 'em
models.subAct.create({
data: {
userId: me.id,
subName: data.name,
msats: cost,
type: 'BILLING'
}
}),
// notify 'em (in the future)
models.subSubscription.create({
data: {
userId: me.id,
subName: data.name
}
})
], { models, lnd, me, hash, hmac, fee: billingCost, verifyPayment: !!hash || !me })
return results[1]
} catch (error) { } catch (error) {
if (error.code === 'P2002') { if (error.code === 'P2002') {
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
@ -493,71 +343,7 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash,
} }
try { try {
// if the cost is changing, record the new cost and update billing job return await performPaidAction('TERRITORY_UPDATE', { oldName, ...data }, { me, models, lnd, hash, hmac })
if (oldSub.billingType !== data.billingType) {
// make sure the current cost is recorded so they are grandfathered in
data.billingCost = TERRITORY_PERIOD_COST(data.billingType)
// we never want to bill them again if they are changing to ONCE
if (data.billingType === 'ONCE') {
data.billPaidUntil = null
data.billingAutoRenew = false
}
// if they are changing to YEARLY, bill them in a year
// if they are changing to MONTHLY from YEARLY, do nothing
if (oldSub.billingType === 'MONTHLY' && data.billingType === 'YEARLY') {
data.billPaidUntil = datePivot(new Date(oldSub.billedLastAt), { years: 1 })
}
// if this billing change makes their bill paid up, set them to active
if (data.billPaidUntil === null || data.billPaidUntil >= new Date()) {
data.status = 'ACTIVE'
}
// if the billing type is changing such that it's more expensive, bill 'em the difference
const proratedCost = proratedBillingCost(oldSub, data.billingType)
if (proratedCost > 0) {
const cost = BigInt(1000) * BigInt(proratedCost)
const results = await serialize([
models.user.update({
where: {
id: me.id
},
data: {
msats: {
decrement: cost
}
}
}),
models.subAct.create({
data: {
userId: me.id,
subName: oldName,
msats: cost,
type: 'BILLING'
}
}),
models.sub.update({
data,
where: {
name: oldName,
userId: me.id
}
})
], { models, lnd, me, hash, hmac, fee: proratedCost, verifyPayment: !!hash || !me })
return results[2]
}
}
// if we get here they are changin in a way that doesn't cost them anything
return await models.sub.update({
data,
where: {
name: oldName,
userId: me.id
}
})
} catch (error) { } catch (error) {
if (error.code === 'P2002') { if (error.code === 'P2002') {
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })

View File

@ -4,8 +4,8 @@ import { GraphQLError } from 'graphql'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate' import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate'
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item' import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item'
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } from '@/lib/constants' import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES } from '@/lib/constants'
import { viewGroup } from './growth' import { viewGroup } from './growth'
import { timeUnitForRange, whenRange } from '@/lib/time' import { timeUnitForRange, whenRange } from '@/lib/time'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
@ -283,6 +283,7 @@ export default {
'"ThreadSubscription"."userId" = $1', '"ThreadSubscription"."userId" = $1',
'r.created_at > $2', 'r.created_at > $2',
'r.created_at >= "ThreadSubscription".created_at', 'r.created_at >= "ThreadSubscription".created_at',
activeOrMine(me),
await filterClause(me, models), await filterClause(me, models),
muteClause(me), muteClause(me),
...(user.noteAllDescendants ? [] : ['r.level = 1']) ...(user.noteAllDescendants ? [] : ['r.level = 1'])
@ -304,6 +305,7 @@ export default {
("Item"."parentId" IS NULL AND "UserSubscription"."postsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."postsSubscribedAt") ("Item"."parentId" IS NULL AND "UserSubscription"."postsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."postsSubscribedAt")
OR ("Item"."parentId" IS NOT NULL AND "UserSubscription"."commentsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."commentsSubscribedAt") OR ("Item"."parentId" IS NOT NULL AND "UserSubscription"."commentsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."commentsSubscribedAt")
)`, )`,
activeOrMine(me),
await filterClause(me, models), await filterClause(me, models),
muteClause(me))})`, me.id, lastChecked) muteClause(me))})`, me.id, lastChecked)
if (newUserSubs.exists) { if (newUserSubs.exists) {
@ -320,6 +322,8 @@ export default {
'"SubSubscription"."userId" = $1', '"SubSubscription"."userId" = $1',
'"Item".created_at > $2', '"Item".created_at > $2',
'"Item"."parentId" IS NULL', '"Item"."parentId" IS NULL',
'"Item"."userId" <> $1',
activeOrMine(me),
await filterClause(me, models), await filterClause(me, models),
muteClause(me))})`, me.id, lastChecked) muteClause(me))})`, me.id, lastChecked)
if (newSubPost.exists) { if (newSubPost.exists) {
@ -338,6 +342,7 @@ export default {
'"Mention"."userId" = $1', '"Mention"."userId" = $1',
'"Mention".created_at > $2', '"Mention".created_at > $2',
'"Item"."userId" <> $1', '"Item"."userId" <> $1',
activeOrMine(me),
await filterClause(me, models), await filterClause(me, models),
muteClause(me) muteClause(me)
)})`, me.id, lastChecked) )})`, me.id, lastChecked)
@ -358,6 +363,7 @@ export default {
'"ItemMention".created_at > $2', '"ItemMention".created_at > $2',
'"Item"."userId" <> $1', '"Item"."userId" <> $1',
'"Referee"."userId" = $1', '"Referee"."userId" = $1',
activeOrMine(me),
await filterClause(me, models), await filterClause(me, models),
muteClause(me) muteClause(me)
)})`, me.id, lastChecked) )})`, me.id, lastChecked)
@ -375,8 +381,13 @@ export default {
JOIN "ItemForward" ON JOIN "ItemForward" ON
"ItemForward"."itemId" = "Item".id "ItemForward"."itemId" = "Item".id
AND "ItemForward"."userId" = $1 AND "ItemForward"."userId" = $1
WHERE "Item"."lastZapAt" > $2 ${whereClause(
AND "Item"."userId" <> $1)`, me.id, lastChecked) '"Item"."lastZapAt" > $2',
'"Item"."userId" <> $1',
activeOrMine(me),
await filterClause(me, models),
muteClause(me)
)})`, me.id, lastChecked)
if (newFwdSats.exists) { if (newFwdSats.exists) {
foundNotes() foundNotes()
return true return true
@ -424,7 +435,8 @@ export default {
confirmedAt: { confirmedAt: {
gt: lastChecked gt: lastChecked
}, },
isHeld: null isHeld: null,
actionType: null
} }
}) })
if (invoice) { if (invoice) {
@ -523,6 +535,24 @@ export default {
return true return true
} }
const invoiceActionFailed = await models.invoice.findFirst({
where: {
userId: me.id,
updatedAt: {
gt: lastChecked
},
actionType: {
in: INVOICE_ACTION_NOTIFICATION_TYPES
},
actionState: 'FAILED'
}
})
if (invoiceActionFailed) {
foundNotes()
return true
}
// update checkedNotesAt to prevent rechecking same time period // update checkedNotesAt to prevent rechecking same time period
models.user.update({ models.user.update({
where: { id: me.id }, where: { id: me.id },

View File

@ -3,7 +3,7 @@ import { GraphQLError } from 'graphql'
import crypto from 'crypto' import crypto from 'crypto'
import serialize from './serial' import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT } from './item' import { SELECT, itemQueryWithMeta } from './item'
import { lnAddrOptions } from '@/lib/lnurl' import { lnAddrOptions } from '@/lib/lnurl'
import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format' import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
import { CLNAutowithdrawSchema, LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate' import { CLNAutowithdrawSchema, LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate'
@ -13,6 +13,7 @@ import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
import { createInvoice as createInvoiceCLN } from '@/lib/cln' import { createInvoice as createInvoiceCLN } from '@/lib/cln'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import { checkInvoice } from 'worker/wallet'
export async function getInvoice (parent, { id }, { me, models, lnd }) { export async function getInvoice (parent, { id }, { me, models, lnd }) {
const inv = await models.invoice.findUnique({ const inv = await models.invoice.findUnique({
@ -215,6 +216,7 @@ export default {
WHERE "ItemAct".act = 'TIP' WHERE "ItemAct".act = 'TIP'
AND ("Item"."userId" = $1 OR "ItemForward"."userId" = $1) AND ("Item"."userId" = $1 OR "ItemForward"."userId" = $1)
AND "ItemAct".created_at <= $2 AND "ItemAct".created_at <= $2
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
GROUP BY "Item".id)` GROUP BY "Item".id)`
) )
queries.push( queries.push(
@ -247,6 +249,7 @@ export default {
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE "ItemAct"."userId" = $1 WHERE "ItemAct"."userId" = $1
AND "ItemAct".created_at <= $2 AND "ItemAct".created_at <= $2
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
GROUP BY "Item".id)` GROUP BY "Item".id)`
) )
queries.push( queries.push(
@ -380,18 +383,9 @@ export default {
throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } })
} }
await cancelHodlInvoice({ id: hash, lnd }) await cancelHodlInvoice({ id: hash, lnd })
const inv = await serialize( // transition invoice to cancelled action state
models.invoice.update({ await checkInvoice({ data: { hash }, models, lnd })
where: { return await models.invoice.findFirst({ where: { hash } })
hash
},
data: {
cancelled: true
}
}),
{ models }
)
return inv
}, },
dropBolt11: async (parent, { id }, { me, models, lnd }) => { dropBolt11: async (parent, { id }, { me, models, lnd }) => {
if (!me) { if (!me) {
@ -545,7 +539,47 @@ export default {
Invoice: { Invoice: {
satsReceived: i => msatsToSats(i.msatsReceived), satsReceived: i => msatsToSats(i.msatsReceived),
satsRequested: i => msatsToSats(i.msatsRequested) satsRequested: i => msatsToSats(i.msatsRequested),
item: async (invoice, args, { models, me }) => {
if (!invoice.actionId) return null
switch (invoice.actionType) {
case 'ITEM_CREATE':
case 'ZAP':
case 'DOWN_ZAP':
case 'POLL_VOTE':
return (await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
WHERE id = $1`
}, Number(invoice.actionId)))?.[0]
default:
return null
}
},
itemAct: async (invoice, args, { models, me }) => {
const action2act = {
ZAP: 'TIP',
DOWN_ZAP: 'DONT_LIKE_THIS',
POLL_VOTE: 'POLL'
}
switch (invoice.actionType) {
case 'ZAP':
case 'DOWN_ZAP':
case 'POLL_VOTE':
return (await models.$queryRaw`
SELECT id, act, "invoiceId", "invoiceActionState", msats
FROM "ItemAct"
WHERE "ItemAct"."invoiceId" = ${Number(invoice.id)}::INTEGER
AND "ItemAct"."userId" = ${me?.id}::INTEGER
AND act = ${action2act[invoice.actionType]}::"ItemActType"`
)?.[0]
default:
return null
}
}
}, },
Fact: { Fact: {

View File

@ -18,6 +18,7 @@ import admin from './admin'
import blockHeight from './blockHeight' import blockHeight from './blockHeight'
import chainFee from './chainFee' import chainFee from './chainFee'
import image from './image' import image from './image'
import paidAction from './paidAction'
const common = gql` const common = gql`
type Query { type Query {
@ -38,4 +39,4 @@ const common = gql`
` `
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, image] sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, image, paidAction]

View File

@ -20,28 +20,38 @@ export default gql`
type ItemActResult { type ItemActResult {
id: ID! id: ID!
sats: Int! sats: Int!
path: String! path: String
act: String! act: String!
} }
type ItemAct {
id: ID!
act: String!
invoice: Invoice
}
extend type Mutation { extend type Mutation {
bookmarkItem(id: ID): Item bookmarkItem(id: ID): Item
pinItem(id: ID): Item pinItem(id: ID): Item
subscribeItem(id: ID): Item subscribeItem(id: ID): Item
deleteItem(id: ID): Item deleteItem(id: ID): Item
upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item! upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): ItemPaidAction!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item! upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): ItemPaidAction!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, hash: String, hmac: String, boost: Int, forward: [ItemForwardInput]): Item! upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, hash: String, hmac: String, boost: Int, forward: [ItemForwardInput]): ItemPaidAction!
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): Item! text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): ItemPaidAction!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String, pollExpiresAt: Date): Item! upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String, pollExpiresAt: Date): ItemPaidAction!
updateNoteId(id: ID!, noteId: String!): Item! updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item! upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): ItemPaidAction!
act(id: ID!, sats: Int, act: String, idempotent: Boolean, hash: String, hmac: String): ItemActResult! act(id: ID!, sats: Int, act: String, idempotent: Boolean, hash: String, hmac: String): ItemActPaidAction!
pollVote(id: ID!, hash: String, hmac: String): ID! pollVote(id: ID!, hash: String, hmac: String): PollVotePaidAction!
toggleOutlaw(id: ID!): Item! toggleOutlaw(id: ID!): Item!
} }
type PollVoteResult {
id: ID!
}
type PollOption { type PollOption {
id: ID, id: ID,
option: String! option: String!
@ -50,6 +60,8 @@ export default gql`
type Poll { type Poll {
meVoted: Boolean! meVoted: Boolean!
meInvoiceId: Int
meInvoiceActionState: InvoiceActionState
count: Int! count: Int!
options: [PollOption!]! options: [PollOption!]!
} }
@ -65,6 +77,14 @@ export default gql`
comments: [Item!]! comments: [Item!]!
} }
enum InvoiceActionState {
PENDING
PENDING_HELD
HELD
PAID
FAILED
}
type Item { type Item {
id: ID! id: ID!
createdAt: Date! createdAt: Date!
@ -125,6 +145,7 @@ export default gql`
imgproxyUrls: JSONObject imgproxyUrls: JSONObject
rel: String rel: String
apiKey: Boolean apiKey: Boolean
invoice: Invoice
} }
input ItemForwardInput { input ItemForwardInput {

View File

@ -55,6 +55,12 @@ export default gql`
sortTime: Date! sortTime: Date!
} }
type Invoicification {
id: ID!
invoice: Invoice!
sortTime: Date!
}
type JobChanged { type JobChanged {
id: ID! id: ID!
item: Item! item: Item!
@ -136,7 +142,7 @@ export default gql`
union Notification = Reply | Votification | Mention union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral | Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus | Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention | TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification
type Notifications { type Notifications {
lastChecked: Date lastChecked: Date

View File

@ -0,0 +1,50 @@
import { gql } from 'graphql-tag'
export default gql`
extend type Mutation {
retryPaidAction(invoiceId: Int!): PaidAction!
}
enum PaymentMethod {
FEE_CREDIT
OPTIMISTIC
PESSIMISTIC
}
interface PaidAction {
invoice: Invoice
paymentMethod: PaymentMethod!
}
type ItemPaidAction implements PaidAction {
result: Item
invoice: Invoice
paymentMethod: PaymentMethod!
}
type ItemActPaidAction implements PaidAction {
result: ItemActResult
invoice: Invoice
paymentMethod: PaymentMethod!
}
type PollVotePaidAction implements PaidAction {
result: PollVoteResult
invoice: Invoice
paymentMethod: PaymentMethod!
}
type SubPaidAction implements PaidAction {
result: Sub
invoice: Invoice
paymentMethod: PaymentMethod!
}
type DonatePaidAction implements PaidAction {
result: DonateResult
invoice: Invoice
paymentMethod: PaymentMethod!
}
`

View File

@ -7,7 +7,11 @@ export default gql`
} }
extend type Mutation { extend type Mutation {
donateToRewards(sats: Int!, hash: String, hmac: String): Int! donateToRewards(sats: Int!, hash: String, hmac: String): DonatePaidAction!
}
type DonateResult {
sats: Int
} }
type Rewards { type Rewards {

View File

@ -18,15 +18,15 @@ export default gql`
upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!, upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!, postTypes: [String!]!, allowFreebies: Boolean!,
billingType: String!, billingAutoRenew: Boolean!, billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): Sub moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): SubPaidAction!
paySub(name: String!, hash: String, hmac: String): Sub paySub(name: String!, hash: String, hmac: String): SubPaidAction!
toggleMuteSub(name: String!): Boolean! toggleMuteSub(name: String!): Boolean!
toggleSubSubscription(name: String!): Boolean! toggleSubSubscription(name: String!): Boolean!
transferTerritory(subName: String!, userName: String!): Sub transferTerritory(subName: String!, userName: String!): Sub
unarchiveTerritory(name: String!, desc: String, baseCost: Int!, unarchiveTerritory(name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!, postTypes: [String!]!, allowFreebies: Boolean!,
billingType: String!, billingAutoRenew: Boolean!, billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): Sub moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): SubPaidAction!
} }
type Sub { type Sub {

View File

@ -74,6 +74,10 @@ export default gql`
hmac: String hmac: String
isHeld: Boolean isHeld: Boolean
confirmedPreimage: String confirmedPreimage: String
actionState: String
actionType: String
item: Item
itemAct: ItemAct
} }
type Withdrawl { type Withdrawl {

View File

@ -1,18 +1,16 @@
import { Form, Input, MarkdownInput } from '@/components/form' import { Form, Input, MarkdownInput } from '@/components/form'
import { useRouter } from 'next/router' import { useApolloClient } from '@apollo/client'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import Countdown from './countdown' import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form' import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import useCrossposter from './use-crossposter'
import { bountySchema } from '@/lib/validate' import { bountySchema } from '@/lib/validate'
import { SubSelectInitial } from './sub-select' import { SubSelectInitial } from './sub-select'
import { useCallback } from 'react' import { normalizeForwards } from '@/lib/form'
import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
import { MAX_TITLE_LENGTH } from '@/lib/constants' import { MAX_TITLE_LENGTH } from '@/lib/constants'
import { useMe } from './me' import { useMe } from './me'
import { useToast } from './toast'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
import useItemSubmit from './use-item-submit'
import { UPSERT_BOUNTY } from '@/fragments/paidAction'
export function BountyForm ({ export function BountyForm ({
item, item,
@ -24,75 +22,11 @@ export function BountyForm ({
handleSubmit, handleSubmit,
children children
}) { }) {
const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const me = useMe()
const toaster = useToast()
const crossposter = useCrossposter()
const schema = bountySchema({ client, me, existingBoost: item?.boost }) const schema = bountySchema({ client, me, existingBoost: item?.boost })
const [upsertBounty] = useMutation(
gql`
mutation upsertBounty(
$sub: String
$id: ID
$title: String!
$bounty: Int!
$text: String
$boost: Int
$forward: [ItemForwardInput]
$hash: String
$hmac: String
) {
upsertBounty(
sub: $sub
id: $id
title: $title
bounty: $bounty
text: $text
boost: $boost
forward: $forward
hash: $hash
hmac: $hmac
) {
id
deleteScheduledAt
reminderScheduledAt
}
}
`
)
const onSubmit = useCallback( const onSubmit = useItemSubmit(UPSERT_BOUNTY, { item, sub })
async ({ boost, bounty, crosspost, ...values }) => {
const { data, error } = await upsertBounty({
variables: {
sub: item?.subName || sub?.name,
id: item?.id,
boost: boost ? Number(boost) : undefined,
bounty: bounty ? Number(bounty) : undefined,
...values,
forward: normalizeForwards(values.forward)
}
})
if (error) {
throw new Error({ message: error.toString() })
}
const bountyId = data?.upsertBounty?.id
if (crosspost && bountyId) {
await crossposter(bountyId)
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
toastUpsertSuccessMessages(toaster, data, 'upsertBounty', !!item, values.text)
}, [upsertBounty, router]
)
const storageKeyPrefix = item ? undefined : 'bounty' const storageKeyPrefix = item ? undefined : 'bounty'
@ -108,7 +42,6 @@ export function BountyForm ({
}} }}
schema={schema} schema={schema}
requireSession requireSession
prepaid
onSubmit={ onSubmit={
handleSubmit || handleSubmit ||
onSubmit onSubmit

View File

@ -1,187 +0,0 @@
import { useApolloClient } from '@apollo/client'
import { useMe } from './me'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { datePivot, timeSince } from '@/lib/time'
import { USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import Item, { ItemSkeleton } from './item'
import { RootProvider } from './root'
import Comment from './comment'
const toType = t => ({ ERROR: `${t}_ERROR`, PENDING: `${t}_PENDING` })
export const Types = {
Zap: toType('ZAP'),
Reply: toType('REPLY'),
Bounty: toType('BOUNTY'),
PollVote: toType('POLL_VOTE')
}
const ClientNotificationContext = createContext({ notifications: [], notify: () => {}, unnotify: () => {} })
export function ClientNotificationProvider ({ children }) {
const [notifications, setNotifications] = useState([])
const client = useApolloClient()
const me = useMe()
// anons don't have access to /notifications
// but we'll store client notifications anyway for simplicity's sake
const storageKey = `client-notifications:${me?.id || USER_ID.anon}`
useEffect(() => {
const loaded = loadNotifications(storageKey, client)
setNotifications(loaded)
}, [storageKey])
const notify = useCallback((type, props) => {
const id = crypto.randomUUID()
const sortTime = new Date()
const expiresAt = +datePivot(sortTime, { milliseconds: JIT_INVOICE_TIMEOUT_MS })
const isError = type.endsWith('ERROR')
const n = { __typename: type, id, sortTime: +sortTime, pending: !isError, expiresAt, ...props }
setNotifications(notifications => [n, ...notifications])
saveNotification(storageKey, n)
if (isError) {
client?.writeQuery({
query: HAS_NOTIFICATIONS,
data: {
hasNewNotes: true
}
})
}
return id
}, [storageKey, client])
const unnotify = useCallback((id) => {
setNotifications(notifications => notifications.filter(n => n.id !== id))
removeNotification(storageKey, id)
}, [storageKey])
const value = useMemo(() => ({ notifications, notify, unnotify }), [notifications, notify, unnotify])
return (
<ClientNotificationContext.Provider value={value}>
{children}
</ClientNotificationContext.Provider>
)
}
export function ClientNotifyProvider ({ children, additionalProps }) {
const ctx = useClientNotifications()
const notify = useCallback((type, props) => {
return ctx.notify(type, { ...props, ...additionalProps })
}, [ctx.notify])
const value = useMemo(() => ({ ...ctx, notify }), [ctx, notify])
return (
<ClientNotificationContext.Provider value={value}>
{children}
</ClientNotificationContext.Provider>
)
}
export function useClientNotifications () {
return useContext(ClientNotificationContext)
}
function ClientNotification ({ n, message }) {
if (n.pending) {
const expired = n.expiresAt < +new Date()
if (!expired) return null
n.reason = 'invoice expired'
}
// remove payment hashes due to x-overflow
n.reason = n.reason.replace(/(: )?[a-f0-9]{64}/, '')
return (
<div className='ms-2'>
<small className='fw-bold text-danger'>
{n.reason ? `${message}: ${n.reason}` : message}
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</small>
{!n.item
? <ItemSkeleton />
: n.item.title
? <Item item={n.item} />
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent noComments clickToContext />
</RootProvider>
</div>
)}
</div>
)
}
export function ClientZap ({ n }) {
const message = `failed to zap ${n.sats || n.amount} sats`
return <ClientNotification n={n} message={message} />
}
export function ClientReply ({ n }) {
const message = 'failed to submit reply'
return <ClientNotification n={n} message={message} />
}
export function ClientBounty ({ n }) {
const message = 'failed to pay bounty'
return <ClientNotification n={n} message={message} />
}
export function ClientPollVote ({ n }) {
const message = 'failed to submit poll vote'
return <ClientNotification n={n} message={message} />
}
function loadNotifications (storageKey, client) {
const stored = window.localStorage.getItem(storageKey)
if (!stored) return []
const filtered = JSON.parse(stored).filter(({ sortTime }) => {
// only keep notifications younger than 24 hours
return new Date(sortTime) >= datePivot(new Date(), { hours: -24 })
})
let hasNewNotes = false
const mapped = filtered.map((n) => {
if (!n.pending) return n
// anything that is still pending when we load the page was interrupted
// so we immediately mark it as failed instead of waiting until it expired
const type = n.__typename.replace('PENDING', 'ERROR')
const reason = 'payment was interrupted'
hasNewNotes = true
return { ...n, __typename: type, pending: false, reason }
})
if (hasNewNotes) {
client?.writeQuery({
query: HAS_NOTIFICATIONS,
data: {
hasNewNotes: true
}
})
}
window.localStorage.setItem(storageKey, JSON.stringify(mapped))
return filtered
}
function saveNotification (storageKey, n) {
const stored = window.localStorage.getItem(storageKey)
if (stored) {
window.localStorage.setItem(storageKey, JSON.stringify([...JSON.parse(stored), n]))
} else {
window.localStorage.setItem(storageKey, JSON.stringify([n]))
}
}
function removeNotification (storageKey, id) {
const stored = window.localStorage.getItem(storageKey)
if (stored) {
window.localStorage.setItem(storageKey, JSON.stringify(JSON.parse(stored).filter(n => n.id !== id)))
}
}

View File

@ -1,35 +1,31 @@
import { Form, MarkdownInput } from '@/components/form' import { Form, MarkdownInput } from '@/components/form'
import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css' import styles from './reply.module.css'
import { commentSchema } from '@/lib/validate' import { commentSchema } from '@/lib/validate'
import { useToast } from './toast'
import { toastUpsertSuccessMessages } from '@/lib/form'
import { FeeButtonProvider } from './fee-button' import { FeeButtonProvider } from './fee-button'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
import { UPDATE_COMMENT } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'
export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) { export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) {
const toaster = useToast() const onSubmit = useItemSubmit(UPDATE_COMMENT, {
const [upsertComment] = useMutation( paidMutationOptions: {
gql` update (cache, { data: { upsertComment: { result } } }) {
mutation upsertComment($id: ID! $text: String!) { if (!result) return
upsertComment(id: $id, text: $text) {
text
deleteScheduledAt
reminderScheduledAt
}
}`, {
update (cache, { data: { upsertComment } }) {
cache.modify({ cache.modify({
id: `Item:${comment.id}`, id: `Item:${comment.id}`,
fields: { fields: {
text () { text () {
return upsertComment.text return result.text
} }
} }
}) })
} }
} },
) item: comment,
navigateOnSubmit: false,
onSuccessfulSubmit: onSuccess
})
return ( return (
<div className={`${styles.reply} mt-2`}> <div className={`${styles.reply} mt-2`}>
@ -39,16 +35,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
text: comment.text text: comment.text
}} }}
schema={commentSchema} schema={commentSchema}
onSubmit={async (values, { resetForm }) => { onSubmit={onSubmit}
const { data, error } = await upsertComment({ variables: { ...values, id: comment.id } })
if (error) {
throw new Error({ message: error.toString() })
}
toastUpsertSuccessMessages(toaster, data, 'upsertComment', true, values.text)
if (onSuccess) {
onSuccess()
}
}}
> >
<MarkdownInput <MarkdownInput
name='text' name='text'

View File

@ -25,7 +25,6 @@ import Skull from '@/svgs/death-skull.svg'
import { commentSubTreeRootId } from '@/lib/item' import { commentSubTreeRootId } from '@/lib/item'
import Pin from '@/svgs/pushpin-fill.svg' import Pin from '@/svgs/pushpin-fill.svg'
import LinkToContext from './link-to-context' import LinkToContext from './link-to-context'
import { ItemContextProvider, useItemContext } from './item'
function Parent ({ item, rootText }) { function Parent ({ item, rootText }) {
const root = useRoot() const root = useRoot()
@ -137,129 +136,117 @@ export default function Comment ({
const bountyPaid = root.bountyPaidTo?.includes(Number(item.id)) const bountyPaid = root.bountyPaidTo?.includes(Number(item.id))
return ( return (
<ItemContextProvider> <div
<div ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`} onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')} onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')} >
> <div className={`${itemStyles.item} ${styles.item}`}>
<div className={`${itemStyles.item} ${styles.item}`}> {item.outlawed && !me?.privates?.wildWestMode
<ZapIcon item={item} pin={pin} me={me} /> ? <Skull className={styles.dontLike} width={24} height={24} />
<div className={`${itemStyles.hunk} ${styles.hunk}`}> : item.meDontLikeSats > item.meSats
<div className='d-flex align-items-center'> ? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
{item.user?.meMute && !includeParent && collapse === 'yep' : pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
? ( <div className={`${itemStyles.hunk} ${styles.hunk}`}>
<span <div className='d-flex align-items-center'>
className={`${itemStyles.other} ${styles.other} pointer`} onClick={() => { {item.user?.meMute && !includeParent && collapse === 'yep'
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
>reply from someone you muted
</span>)
: <ItemInfo
item={item}
commentsText='replies'
commentTextSingular='reply'
className={`${itemStyles.other} ${styles.other}`}
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
onQuoteReply={quoteReply}
nested={!includeParent}
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${numWithUnits(root.bounty)} paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>}
</>
}
onEdit={e => { setEdit(!edit) }}
editText={edit ? 'cancel' : 'edit'}
/>}
{!includeParent && (collapse === 'yep'
? <Eye
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
/>
: <EyeClose
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('yep')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
}}
/>)}
{topLevel && (
<span className='d-flex ms-auto align-items-center'>
<Share title={item?.title} path={`/items/${item?.id}`} />
</span>
)}
</div>
{edit
? ( ? (
<CommentEdit <span
comment={item} className={`${itemStyles.other} ${styles.other} pointer`} onClick={() => {
onSuccess={() => { setCollapse('nope')
setEdit(!edit) window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
>reply from someone you muted
</span>)
: <ItemInfo
item={item}
commentsText='replies'
commentTextSingular='reply'
className={`${itemStyles.other} ${styles.other}`}
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
onQuoteReply={quoteReply}
nested={!includeParent}
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${numWithUnits(root.bounty)} paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>}
</>
}
onEdit={e => { setEdit(!edit) }}
editText={edit ? 'cancel' : 'edit'}
/>}
{!includeParent && (collapse === 'yep'
? <Eye
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}} }}
/> />
) : <EyeClose
: ( className={styles.collapser} height={10} width={10} onClick={() => {
<div className={styles.text} ref={textRef}> setCollapse('yep')
{item.searchText window.localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
? <SearchText text={item.searchText} /> }}
: ( />)}
<Text itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}> {topLevel && (
{item.outlawed && !me?.privates?.wildWestMode <span className='d-flex ms-auto align-items-center'>
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*' <Share title={item?.title} path={`/items/${item?.id}`} />
: truncate ? truncateString(item.text) : item.text} </span>
</Text>)} )}
</div>
)}
</div> </div>
</div> {edit
{collapse !== 'yep' && ( ? (
bottomedOut <CommentEdit
? <div className={styles.children}><ReplyOnAnotherPage item={item} /></div> comment={item}
: ( onSuccess={() => {
<div className={styles.children}> setEdit(!edit)
{item.outlawed && !me?.privates?.wildWestMode }}
? <div className='py-2' /> />
: !noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}>
{root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>}
{children}
<div className={styles.comments}>
{item.comments && !noComments
? item.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
))
: null}
</div>
</div>
) )
)} : (
<div className={styles.text} ref={textRef}>
{item.searchText
? <SearchText text={item.searchText} />
: (
<Text itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>
{item.outlawed && !me?.privates?.wildWestMode
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
: truncate ? truncateString(item.text) : item.text}
</Text>)}
</div>
)}
</div>
</div> </div>
</ItemContextProvider> {collapse !== 'yep' && (
bottomedOut
? <div className={styles.children}><ReplyOnAnotherPage item={item} /></div>
: (
<div className={styles.children}>
{item.outlawed && !me?.privates?.wildWestMode
? <div className='py-2' />
: !noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}>
{root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>}
{children}
<div className={styles.comments}>
{item.comments && !noComments
? item.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
))
: null}
</div>
</div>
)
)}
</div>
) )
} }
function ZapIcon ({ item, pin }) {
const me = useMe()
const { pendingSats, pendingDownSats } = useItemContext()
const meSats = item.meSats + pendingSats
const downSats = item.meDontLikeSats + pendingDownSats
return item.outlawed && !me?.privates?.wildWestMode
? <Skull className={styles.dontLike} width={24} height={24} />
: downSats > meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />
}
export function CommentSkeleton ({ skeletonChildren }) { export function CommentSkeleton ({ skeletonChildren }) {
return ( return (
<div className={styles.comment}> <div className={styles.comment}>

View File

@ -6,12 +6,10 @@ import Navbar from 'react-bootstrap/Navbar'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import { defaultCommentSort } from '@/lib/item' import { defaultCommentSort } from '@/lib/item'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { ItemContextProvider, useItemContext } from './item'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter() const router = useRouter()
const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt)
const { pendingCommentSats } = useItemContext()
const getHandleClick = sort => { const getHandleClick = sort => {
return () => { return () => {
@ -26,7 +24,7 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
activeKey={sort} activeKey={sort}
> >
<Nav.Item className='text-muted'> <Nav.Item className='text-muted'>
{numWithUnits(commentSats + pendingCommentSats)} {numWithUnits(commentSats)}
</Nav.Item> </Nav.Item>
<div className='ms-auto d-flex'> <div className='ms-auto d-flex'>
<Nav.Item> <Nav.Item>
@ -68,7 +66,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position) const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position)
return ( return (
<ItemContextProvider> <>
{comments?.length > 0 {comments?.length > 0
? <CommentsHeader ? <CommentsHeader
commentSats={commentSats} parentCreatedAt={parentCreatedAt} commentSats={commentSats} parentCreatedAt={parentCreatedAt}
@ -93,7 +91,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
{comments.filter(({ position }) => !position).map(item => ( {comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} {...props} /> <Comment depth={1} key={item.id} item={item} {...props} />
))} ))}
</ItemContextProvider> </>
) )
} }

View File

@ -39,7 +39,13 @@ export function CompactLongCountdown (props) {
<> <>
{Number(props.formatted.days) > 0 {Number(props.formatted.days) > 0
? ` ${props.formatted.days}d ${props.formatted.hours}h ${props.formatted.minutes}m ${props.formatted.seconds}s` ? ` ${props.formatted.days}d ${props.formatted.hours}h ${props.formatted.minutes}m ${props.formatted.seconds}s`
: ` ${props.formatted.hours}:${props.formatted.minutes}:${props.formatted.seconds}`} : Number(props.formatted.hours) > 0
? ` ${props.formatted.hours}:${props.formatted.minutes}:${props.formatted.seconds}`
: Number(props.formatted.minutes) > 0
? ` ${props.formatted.minutes}:${props.formatted.seconds}`
: Number(props.formatted.seconds) > 0
? ` ${props.formatted.seconds}s`
: ' '}
</> </>
) )
}} }}

View File

@ -45,7 +45,7 @@ export default function Delete ({ itemId, children, onDelete, type = 'post' }) {
onConfirm={async () => { onConfirm={async () => {
const { error } = await deleteItem({ variables: { id: itemId } }) const { error } = await deleteItem({ variables: { id: itemId } })
if (error) { if (error) {
throw new Error({ message: error.toString() }) throw error
} }
if (onDelete) { if (onDelete) {
onDelete() onDelete()

View File

@ -1,6 +1,6 @@
import { Form, Input, MarkdownInput } from '@/components/form' import { Form, Input, MarkdownInput } from '@/components/form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { gql, useApolloClient, useLazyQuery } from '@apollo/client'
import Countdown from './countdown' import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form' import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { ITEM_FIELDS } from '@/fragments/items' import { ITEM_FIELDS } from '@/fragments/items'
@ -8,13 +8,12 @@ import AccordianItem from './accordian-item'
import Item from './item' import Item from './item'
import { discussionSchema } from '@/lib/validate' import { discussionSchema } from '@/lib/validate'
import { SubSelectInitial } from './sub-select' import { SubSelectInitial } from './sub-select'
import { useCallback } from 'react' import { normalizeForwards } from '@/lib/form'
import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
import { MAX_TITLE_LENGTH } from '@/lib/constants' import { MAX_TITLE_LENGTH } from '@/lib/constants'
import { useMe } from './me' import { useMe } from './me'
import useCrossposter from './use-crossposter'
import { useToast } from './toast'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
import { UPSERT_DISCUSSION } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'
export function DiscussionForm ({ export function DiscussionForm ({
item, sub, editThreshold, titleLabel = 'title', item, sub, editThreshold, titleLabel = 'title',
@ -24,55 +23,11 @@ export function DiscussionForm ({
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const me = useMe()
const onSubmit = useItemSubmit(UPSERT_DISCUSSION, { item, sub })
const schema = discussionSchema({ client, me, existingBoost: item?.boost }) const schema = discussionSchema({ client, me, existingBoost: item?.boost })
// if Web Share Target API was used // if Web Share Target API was used
const shareTitle = router.query.title const shareTitle = router.query.title
const shareText = router.query.text ? decodeURI(router.query.text) : undefined const shareText = router.query.text ? decodeURI(router.query.text) : undefined
const crossposter = useCrossposter()
const toaster = useToast()
const [upsertDiscussion] = useMutation(
gql`
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
id
deleteScheduledAt
reminderScheduledAt
}
}`
)
const onSubmit = useCallback(
async ({ boost, crosspost, ...values }) => {
const { data, error } = await upsertDiscussion({
variables: {
sub: item?.subName || sub?.name,
id: item?.id,
boost: boost ? Number(boost) : undefined,
...values,
forward: normalizeForwards(values.forward)
}
})
if (error) {
throw new Error({ message: error.toString() })
}
const discussionId = data?.upsertDiscussion?.id
if (crosspost && discussionId) {
await crossposter(discussionId)
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
toastUpsertSuccessMessages(toaster, data, 'upsertDiscussion', !!item, values.text)
}, [upsertDiscussion, router, item, sub, crossposter]
)
const [getRelated, { data: relatedData }] = useLazyQuery(gql` const [getRelated, { data: relatedData }] = useLazyQuery(gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
@ -96,7 +51,6 @@ export function DiscussionForm ({
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}
prepaid
onSubmit={handleSubmit || onSubmit} onSubmit={handleSubmit || onSubmit}
storageKeyPrefix={storageKeyPrefix} storageKeyPrefix={storageKeyPrefix}
> >

View File

@ -4,24 +4,18 @@ import { useToast } from './toast'
import ItemAct from './item-act' import ItemAct from './item-act'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import Flag from '@/svgs/flag-fill.svg' import Flag from '@/svgs/flag-fill.svg'
import { useCallback, useMemo } from 'react' import { useMemo } from 'react'
import getColor from '@/lib/rainbow' import getColor from '@/lib/rainbow'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import { useItemContext } from './item'
import { useLightning } from './lightning'
export function DownZap ({ item, ...props }) { export function DownZap ({ item, ...props }) {
const { pendingDownSats } = useItemContext()
const { meDontLikeSats } = item const { meDontLikeSats } = item
const style = useMemo(() => (meDontLikeSats
const downSats = meDontLikeSats + pendingDownSats
const style = useMemo(() => (downSats
? { ? {
fill: getColor(downSats), fill: getColor(meDontLikeSats),
filter: `drop-shadow(0 0 6px ${getColor(downSats)}90)` filter: `drop-shadow(0 0 6px ${getColor(meDontLikeSats)}90)`
} }
: undefined), [downSats]) : undefined), [meDontLikeSats])
return ( return (
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} /> <DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
) )
@ -30,17 +24,6 @@ export function DownZap ({ item, ...props }) {
function DownZapper ({ item, As, children }) { function DownZapper ({ item, As, children }) {
const toaster = useToast() const toaster = useToast()
const showModal = useShowModal() const showModal = useShowModal()
const strike = useLightning()
const { setPendingDownSats } = useItemContext()
const optimisticUpdate = useCallback((sats, { onClose } = {}) => {
setPendingDownSats(pendingSats => pendingSats + sats)
strike()
onClose?.()
return () => {
setPendingDownSats(pendingSats => pendingSats - sats)
}
}, [])
return ( return (
<As <As
@ -48,7 +31,7 @@ function DownZapper ({ item, As, children }) {
try { try {
showModal(onClose => showModal(onClose =>
<ItemAct <ItemAct
onClose={onClose} item={item} down optimisticUpdate={optimisticUpdate} onClose={onClose} item={item} down
> >
<AccordianItem <AccordianItem
header='what is a downzap?' body={ header='what is a downzap?' body={

View File

@ -127,7 +127,12 @@ export default function FeeButton ({ ChildButton = SubmitButton, variant, text,
return ( return (
<div className={styles.feeButton}> <div className={styles.feeButton}>
<ActionTooltip overlayText={!free && total === 1 ? '1 sat' : feeText}> <ActionTooltip overlayText={!free && total === 1 ? '1 sat' : feeText}>
<ChildButton variant={variant} disabled={disabled} nonDisabledText={feeText}>{text}</ChildButton> <ChildButton
variant={variant} disabled={disabled}
appendText={feeText}
submittingText={free || !feeText ? undefined : 'paying...'}
>{text}
</ChildButton>
</ActionTooltip> </ActionTooltip>
{!me && <AnonInfo />} {!me && <AnonInfo />}
{(free && <Info><FreebieDialog /></Info>) || {(free && <Info><FreebieDialog /></Info>) ||

View File

@ -31,10 +31,8 @@ import Thumb from '@/svgs/thumb-up-fill.svg'
import Eye from '@/svgs/eye-fill.svg' import Eye from '@/svgs/eye-fill.svg'
import EyeClose from '@/svgs/eye-close-line.svg' import EyeClose from '@/svgs/eye-close-line.svg'
import Info from './info' import Info from './info'
import { InvoiceCanceledError, usePayment } from './payment'
import { useMe } from './me' import { useMe } from './me'
import { useClientNotifications } from './client-notifications' import classNames from 'classnames'
import { ActCanceledError } from './item-act'
export class SessionRequiredError extends Error { export class SessionRequiredError extends Error {
constructor () { constructor () {
@ -44,15 +42,18 @@ export class SessionRequiredError extends Error {
} }
export function SubmitButton ({ export function SubmitButton ({
children, variant, value, onClick, disabled, nonDisabledText, ...props children, variant, value, onClick, disabled, appendText, submittingText,
className, ...props
}) { }) {
const formik = useFormikContext() const formik = useFormikContext()
disabled ||= formik.isSubmitting disabled ||= formik.isSubmitting
submittingText ||= children
return ( return (
<Button <Button
variant={variant || 'main'} variant={variant || 'main'}
className={classNames(formik.isSubmitting && styles.pending, className)}
type='submit' type='submit'
disabled={disabled} disabled={disabled}
onClick={value onClick={value
@ -63,7 +64,7 @@ export function SubmitButton ({
: onClick} : onClick}
{...props} {...props}
> >
{children}{!disabled && nonDisabledText && <small> {nonDisabledText}</small>} {formik.isSubmitting ? submittingText : children}{!disabled && appendText && <small> {appendText}</small>}
</Button> </Button>
) )
} }
@ -802,15 +803,12 @@ const StorageKeyPrefixContext = createContext()
export function Form ({ export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately, initial, schema, onSubmit, children, initialError, validateImmediately,
storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef, storageKeyPrefix, validateOnChange = true, requireSession, innerRef,
optimisticUpdate, clientNotification, signal, ...props ...props
}) { }) {
const toaster = useToast() const toaster = useToast()
const initialErrorToasted = useRef(false) const initialErrorToasted = useRef(false)
const feeButton = useFeeButton()
const payment = usePayment()
const me = useMe() const me = useMe()
const { notify, unnotify } = useClientNotifications()
useEffect(() => { useEffect(() => {
if (initialError && !initialErrorToasted.current) { if (initialError && !initialErrorToasted.current) {
@ -836,52 +834,23 @@ export function Form ({
const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => { const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => {
const variables = { amount, ...values } const variables = { amount, ...values }
let revert, cancel, nid if (requireSession && !me) {
throw new SessionRequiredError()
}
try { try {
if (onSubmit) { if (onSubmit) {
if (requireSession && !me) { await onSubmit(variables, ...args)
throw new SessionRequiredError()
}
revert = optimisticUpdate?.(variables)
await signal?.pause({ me, amount })
if (me && clientNotification) {
nid = notify(clientNotification.PENDING, variables)
}
let hash, hmac
if (prepaid) {
[{ hash, hmac }, cancel] = await payment.request(amount)
}
await onSubmit({ hash, hmac, ...variables }, ...args)
if (!storageKeyPrefix) return
clearLocalStorage(values)
} }
} catch (err) { } catch (err) {
if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) { console.log(err.message, err)
return toaster.danger(err.message ?? err.toString?.())
} return
const reason = err.message || err.toString?.()
if (me && clientNotification) {
notify(clientNotification.ERROR, { ...variables, reason })
} else {
toaster.danger('submit error: ' + reason)
}
cancel?.()
} finally {
revert?.()
// if we reach this line, the submit either failed or was successful so we can remove the pending notification.
// if we don't reach this line, the page was probably reloaded and we can use the pending notification
// stored in localStorage to handle this case.
if (nid) unnotify(nid)
} }
}, [me, onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix, payment, signal])
if (!storageKeyPrefix) return
clearLocalStorage(values)
}, [me, onSubmit, clearLocalStorage, storageKeyPrefix])
return ( return (
<Formik <Formik

View File

@ -55,4 +55,18 @@
.previewTab { .previewTab {
padding-top: .2rem; padding-top: .2rem;
padding-bottom: .3rem; padding-bottom: .3rem;
}
.pending {
animation-name: pulse;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
animation-duration: 0.66s;
animation-direction: alternate;
}
@keyframes pulse {
0% {
opacity: 42%;
}
} }

View File

@ -4,7 +4,7 @@ import ThumbDown from '@/svgs/thumb-down-fill.svg'
function InvoiceDefaultStatus ({ status }) { function InvoiceDefaultStatus ({ status }) {
return ( return (
<div className='d-flex mt-2 justify-content-center align-items-center'> <div className='d-flex mt-1 justify-content-center align-items-center'>
<Moon className='spin fill-grey' /> <Moon className='spin fill-grey' />
<div className='ms-3 text-muted' style={{ fontWeight: '600' }}>{status}</div> <div className='ms-3 text-muted' style={{ fontWeight: '600' }}>{status}</div>
</div> </div>
@ -13,7 +13,7 @@ function InvoiceDefaultStatus ({ status }) {
function InvoiceConfirmedStatus ({ status }) { function InvoiceConfirmedStatus ({ status }) {
return ( return (
<div className='d-flex mt-2 justify-content-center align-items-center'> <div className='d-flex mt-1 justify-content-center align-items-center'>
<Check className='fill-success' /> <Check className='fill-success' />
<div className='ms-3 text-success' style={{ fontWeight: '600' }}>{status}</div> <div className='ms-3 text-success' style={{ fontWeight: '600' }}>{status}</div>
</div> </div>
@ -22,19 +22,29 @@ function InvoiceConfirmedStatus ({ status }) {
function InvoiceFailedStatus ({ status }) { function InvoiceFailedStatus ({ status }) {
return ( return (
<div className='d-flex mt-2 justify-content-center align-items-center'> <div className='d-flex mt-1 justify-content-center align-items-center'>
<ThumbDown className='fill-danger' /> <ThumbDown className='fill-danger' />
<div className='ms-3 text-danger' style={{ fontWeight: '600' }}>{status}</div> <div className='ms-3 text-danger' style={{ fontWeight: '600' }}>{status}</div>
</div> </div>
) )
} }
function InvoicePendingStatus ({ status }) {
return (
<div className='d-flex mt-1 text-muted justify-content-center align-items-center'>
{status}
</div>
)
}
export default function InvoiceStatus ({ variant, status }) { export default function InvoiceStatus ({ variant, status }) {
switch (variant) { switch (variant) {
case 'confirmed': case 'confirmed':
return <InvoiceConfirmedStatus status={status} /> return <InvoiceConfirmedStatus status={status} />
case 'failed': case 'failed':
return <InvoiceFailedStatus status={status} /> return <InvoiceFailedStatus status={status} />
case 'pending':
return <InvoicePendingStatus status={status} />
default: default:
return <InvoiceDefaultStatus status={status} /> return <InvoiceDefaultStatus status={status} />
} }

View File

@ -1,37 +1,49 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import Qr from './qr' import Qr, { QrSkeleton } from './qr'
import Countdown from './countdown' import { CompactLongCountdown } from './countdown'
import PayerData from './payer-data' import PayerData from './payer-data'
import Bolt11Info from './bolt11-info' import Bolt11Info from './bolt11-info'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import { INVOICE } from '@/fragments/wallet' import { INVOICE } from '@/fragments/wallet'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants' import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { WebLnNotEnabledError } from './payment' import { WebLnNotEnabledError } from './payment'
import ItemJob from './item-job'
import Item from './item'
import { CommentFlat } from './comment'
import classNames from 'classnames'
export default function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn, webLnError, poll }) { export default function Invoice ({ id, query = INVOICE, modal, onPayment, info, successVerb, webLn = true, webLnError, poll, waitFor, ...props }) {
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date()) const [expired, setExpired] = useState(false)
const { data, error } = useQuery(query, SSR
const { data, error } = useQuery(INVOICE, SSR
? {} ? {}
: { : {
pollInterval: FAST_POLL_INTERVAL, pollInterval: FAST_POLL_INTERVAL,
variables: { id: invoice.id }, variables: { id },
nextFetchPolicy: 'cache-and-network', nextFetchPolicy: 'cache-and-network',
skip: !poll skip: !poll
}) })
if (data) { const invoice = data?.invoice
invoice = data.invoice
} useEffect(() => {
if (!invoice) {
return
}
if (waitFor?.(invoice)) {
onPayment?.(invoice)
}
setExpired(new Date(invoice.expiredAt) <= new Date())
}, [invoice, onPayment, setExpired])
if (error) { if (error) {
return <div>{error.toString()}</div> return <div>{error.message}</div>
} }
// if webLn was not passed, use true by default if (!invoice) {
if (webLn === undefined) webLn = true return <QrSkeleton {...props} />
}
let variant = 'default' let variant = 'default'
let status = 'waiting for you' let status = 'waiting for you'
@ -48,43 +60,31 @@ export default function Invoice ({ invoice, modal, onPayment, info, successVerb,
variant = 'failed' variant = 'failed'
status = 'expired' status = 'expired'
webLn = false webLn = false
} else if (invoice.expiresAt) {
variant = 'pending'
status = (
<CompactLongCountdown
date={invoice.expiresAt} onComplete={() => {
setExpired(true)
}}
/>
)
} }
useEffect(() => {
if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived)) {
onPayment?.(invoice)
}
}, [invoice.confirmedAt, invoice.isHeld, invoice.satsReceived])
const { nostr, comment, lud18Data, bolt11, confirmedPreimage } = invoice const { nostr, comment, lud18Data, bolt11, confirmedPreimage } = invoice
return ( return (
<> <>
{webLnError && !(webLnError instanceof WebLnNotEnabledError) && {webLnError && !(webLnError instanceof WebLnNotEnabledError) &&
<div className='text-center text-danger mb-3'> <div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
Payment from attached wallet failed: Paying from attached wallet failed:
<div>{webLnError.toString()}</div> <code> {webLnError.message}</code>
</div>} </div>}
<Qr <Qr
webLn={webLn} value={invoice.bolt11} webLn={webLn} value={invoice.bolt11}
description={numWithUnits(invoice.satsRequested, { abbreviate: false })} description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
statusVariant={variant} status={status} statusVariant={variant} status={status}
/> />
{invoice.confirmedAt
? (
<div className='text-muted text-center invisible'>
<Countdown date={Date.now()} />
</div>
)
: (
<div className='text-muted text-center'>
<Countdown
date={invoice.expiresAt} onComplete={() => {
setExpired(true)
}}
/>
</div>
)}
{!modal && {!modal &&
<> <>
{info && <div className='text-muted fst-italic text-center'>{info}</div>} {info && <div className='text-muted fst-italic text-center'>{info}</div>}
@ -117,8 +117,53 @@ export default function Invoice ({ invoice, modal, onPayment, info, successVerb,
/> />
</div>} </div>}
<Bolt11Info bolt11={bolt11} preimage={confirmedPreimage} /> <Bolt11Info bolt11={bolt11} preimage={confirmedPreimage} />
{invoice?.item && <ActionInfo invoice={invoice} />}
</>} </>}
</> </>
) )
} }
function ActionInfo ({ invoice }) {
if (!invoice.actionType) return null
let className = 'text-info'
let actionString = ''
switch (invoice.actionState) {
case 'FAILED':
case 'RETRYING':
actionString += 'attempted '
className = 'text-warning'
break
case 'PAID':
actionString += 'successful '
className = 'text-success'
break
default:
actionString += 'pending '
}
switch (invoice.actionType) {
case 'ITEM_CREATE':
actionString += 'item creation'
break
case 'ZAP':
actionString += 'zap on item'
break
case 'DOWN_ZAP':
actionString += 'downzap on item'
break
case 'POLL_VOTE':
actionString += 'poll vote'
break
}
return (
<div className='text-start w-100 my-3'>
<div className={classNames('fw-bold', 'pb-1', className)}>{actionString}</div>
{(invoice.item?.isJob && <ItemJob item={invoice?.item} />) ||
(invoice.item?.title && <Item item={invoice?.item} />) ||
<CommentFlat item={invoice.item} includeParent noReply truncate />}
</div>
)
}

View File

@ -5,14 +5,13 @@ import { Form, Input, SubmitButton } from './form'
import { useMe } from './me' import { useMe } from './me'
import UpBolt from '@/svgs/bolt.svg' import UpBolt from '@/svgs/bolt.svg'
import { amountSchema } from '@/lib/validate' import { amountSchema } from '@/lib/validate'
import { gql, useMutation } from '@apollo/client'
import { useToast } from './toast' import { useToast } from './toast'
import { useLightning } from './lightning' import { useLightning } from './lightning'
import { nextTip } from './upvote' import { nextTip } from './upvote'
import { InvoiceCanceledError, usePayment } from './payment' import { InvoiceCanceledError } from './payment'
import { Types as ClientNotification, ClientNotifyProvider, useClientNotifications } from './client-notifications'
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants' import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { useItemContext } from './item' import { usePaidMutation } from './use-paid-mutation'
import { ACT_MUTATION } from '@/fragments/paidAction'
const defaultTips = [100, 1000, 10_000, 100_000] const defaultTips = [100, 1000, 10_000, 100_000]
@ -50,9 +49,87 @@ const setItemMeAnonSats = ({ id, amount }) => {
window.localStorage.setItem(storageKey, existingAmount + amount) window.localStorage.setItem(storageKey, existingAmount + amount)
} }
export const actUpdate = ({ me, onUpdate }) => (cache, args) => { export default function ItemAct ({ onClose, item, down, children, abortSignal }) {
const { data: { act: { id, sats, path, act } } } = args const inputRef = useRef(null)
const me = useMe()
const [oValue, setOValue] = useState()
useEffect(() => {
inputRef.current?.focus()
}, [onClose, item.id])
const act = useAct()
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
if (abortSignal && zapUndoTrigger({ me, amount })) {
onClose?.()
try {
await abortSignal.pause({ me, amount })
} catch (error) {
if (error instanceof ActCanceledError) {
return
}
}
}
await act({
variables: {
id: item.id,
sats: Number(amount),
act: down ? 'DONT_LIKE_THIS' : 'TIP',
hash,
hmac
},
optimisticResponse: me
? {
act: {
result: {
id: item.id, sats: Number(amount), act: down ? 'DONT_LIKE_THIS' : 'TIP', path: item.path
}
}
}
: undefined,
// don't close modal immediately because we want the QR modal to stack
onCompleted: () => {
onClose?.()
if (!me) setItemMeAnonSats({ id: item.id, amount })
}
})
addCustomTip(Number(amount))
}, [me, act, down, item.id, onClose, abortSignal])
return (
<Form
initial={{
amount: me?.privates?.tipDefault || defaultTips[0],
default: false
}}
schema={amountSchema}
onSubmit={onSubmit}
>
<Input
label='amount'
name='amount'
type='number'
innerRef={inputRef}
overrideValue={oValue}
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div>
<Tips setOValue={setOValue} />
</div>
{children}
<div className='d-flex mt-3'>
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
</div>
</Form>
)
}
function modifyActCache (cache, { result, invoice }) {
if (!result) return
const { id, sats, path, act } = result
cache.modify({ cache.modify({
id: `Item:${id}`, id: `Item:${id}`,
fields: { fields: {
@ -60,25 +137,20 @@ export const actUpdate = ({ me, onUpdate }) => (cache, args) => {
if (act === 'TIP') { if (act === 'TIP') {
return existingSats + sats return existingSats + sats
} }
return existingSats return existingSats
}, },
meSats: (existingSats = 0) => { meSats: (existingSats = 0) => {
if (act === 'TIP') { if (act === 'TIP') {
return existingSats + sats return existingSats + sats
} }
return existingSats return existingSats
}, },
meDontLikeSats: me meDontLikeSats: (existingSats = 0) => {
? (existingSats = 0) => { if (act === 'DONT_LIKE_THIS') {
if (act === 'DONT_LIKE_THIS') { return existingSats + sats
return existingSats + sats }
} return existingSats
}
return existingSats
}
: undefined
} }
}) })
@ -96,201 +168,68 @@ export const actUpdate = ({ me, onUpdate }) => (cache, args) => {
}) })
}) })
} }
onUpdate?.(cache, args)
} }
export default function ItemAct ({ onClose, item, down, children, abortSignal, optimisticUpdate }) { export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
const inputRef = useRef(null) // because the mutation name we use varies,
const me = useMe() // we need to extract the result/invoice from the response
const [oValue, setOValue] = useState() const getPaidActionResult = data => Object.values(data)[0]
const strike = useLightning()
useEffect(() => { const [act] = usePaidMutation(query, {
inputRef.current?.focus() ...options,
}, [onClose, item.id]) update: (cache, { data }) => {
const response = getPaidActionResult(data)
const act = useAct() if (!response) return
modifyActCache(cache, response)
const onSubmit = useCallback(async ({ amount, hash, hmac }) => { options?.update?.(cache, { data })
await act({ if (response.result) strike()
variables: { },
id: item.id, onPayError: (e, cache, { data }) => {
sats: Number(amount), const response = getPaidActionResult(data)
act: down ? 'DONT_LIKE_THIS' : 'TIP', if (!response || !response.result) return
hash, const { result: { sats } } = response
hmac const negate = { ...response, result: { ...response.result, sats: -1 * sats } }
}, modifyActCache(cache, negate)
update: actUpdate({ me }) options?.onPayError?.(e, cache, { data })
}) },
if (!me) setItemMeAnonSats({ id: item.id, amount }) onPaid: (cache, { data }) => {
addCustomTip(Number(amount)) const response = getPaidActionResult(data)
}, [me, act, down, item.id]) if (!response) return
options?.onPaid?.(cache, { data })
return (
<ClientNotifyProvider additionalProps={{ itemId: item.id }}>
<Form
initial={{
amount: me?.privates?.tipDefault || defaultTips[0],
default: false
}}
schema={amountSchema}
prepaid
optimisticUpdate={({ amount }) => optimisticUpdate(amount, { onClose })}
onSubmit={onSubmit}
clientNotification={ClientNotification.Zap}
signal={abortSignal}
>
<Input
label='amount'
name='amount'
type='number'
innerRef={inputRef}
overrideValue={oValue}
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div>
<Tips setOValue={setOValue} />
</div>
{children}
<div className='d-flex mt-3'>
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
</div>
</Form>
</ClientNotifyProvider>
)
}
export const ACT_MUTATION = gql`
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
id
sats
path
act
} }
}` })
export function useAct ({ onUpdate } = {}) {
const [act] = useMutation(ACT_MUTATION)
return act return act
} }
export function useZap () { export function useZap () {
const update = useCallback((cache, args) => { const act = useAct()
const { data: { act: { id, sats, path } } } = args
// determine how much we increased existing sats by by checking the
// difference between result sats and meSats
// if it's negative, skip the cache as it's an out of order update
// if it's positive, add it to sats and commentSats
const item = cache.readFragment({
id: `Item:${id}`,
fragment: gql`
fragment ItemMeSatsZap on Item {
meSats
}
`
})
const satsDelta = sats - item.meSats
if (satsDelta > 0) {
cache.modify({
id: `Item:${id}`,
fields: {
sats (existingSats = 0) {
return existingSats + satsDelta
},
meSats: () => {
return sats
}
}
})
// update all ancestors
path.split('.').forEach(aId => {
if (Number(aId) === Number(id)) return
cache.modify({
id: `Item:${aId}`,
fields: {
commentSats (existingCommentSats = 0) {
return existingCommentSats + satsDelta
}
}
})
})
}
}, [])
const ZAP_MUTATION = gql`
mutation idempotentAct($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac, idempotent: true) {
id
sats
path
}
}`
const [zap] = useMutation(ZAP_MUTATION)
const me = useMe() const me = useMe()
const { notify, unnotify } = useClientNotifications()
const toaster = useToast() const toaster = useToast()
const strike = useLightning()
const payment = usePayment()
const { pendingSats } = useItemContext()
return useCallback(async ({ item, abortSignal, optimisticUpdate }) => { return useCallback(async ({ item, abortSignal }) => {
const meSats = (item?.meSats || 0) + pendingSats const meSats = (item?.meSats || 0)
// add current sats to next tip since idempotent zaps use desired total zap not difference // add current sats to next tip since idempotent zaps use desired total zap not difference
const sats = meSats + nextTip(meSats, { ...me?.privates }) const sats = nextTip(meSats, { ...me?.privates })
const satsDelta = sats - meSats
const variables = { id: item.id, sats, act: 'TIP' } const variables = { id: item.id, sats, act: 'TIP' }
const notifyProps = { itemId: item.id, sats: satsDelta } const optimisticResponse = { act: { result: { path: item.path, ...variables } } }
// const optimisticResponse = { act: { path: item.path, ...variables } }
let revert, cancel, nid
try { try {
revert = optimisticUpdate?.(satsDelta) await abortSignal.pause({ me, amount: sats })
await act({ variables, optimisticResponse })
await abortSignal.pause({ me, amount: satsDelta })
if (me) {
nid = notify(ClientNotification.Zap.PENDING, notifyProps)
}
let hash, hmac;
[{ hash, hmac }, cancel] = await payment.request(satsDelta)
await zap({
variables: { ...variables, hash, hmac },
update: (...args) => {
revert?.()
update(...args)
}
})
} catch (error) { } catch (error) {
revert?.()
if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) { if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
return return
} }
const reason = error?.message || error?.toString?.() const reason = error?.message || error?.toString?.()
if (me) {
notify(ClientNotification.Zap.ERROR, { ...notifyProps, reason })
} else {
toaster.danger('zap failed: ' + reason)
}
cancel?.() toaster.danger('zap failed: ' + reason)
} finally {
if (nid) unnotify(nid)
} }
}, [me?.id, strike, payment, notify, unnotify, pendingSats]) }, [me?.id])
} }
export class ActCanceledError extends Error { export class ActCanceledError extends Error {
@ -301,10 +240,10 @@ export class ActCanceledError extends Error {
} }
export class ZapUndoController extends AbortController { export class ZapUndoController extends AbortController {
constructor () { constructor ({ onStart = () => {}, onDone = () => {} }) {
super() super()
this.signal.start = () => { this.started = true } this.signal.start = onStart
this.signal.done = () => { this.done = true } this.signal.done = onDone
this.signal.pause = async ({ me, amount }) => { this.signal.pause = async ({ me, amount }) => {
if (zapUndoTrigger({ me, amount })) { if (zapUndoTrigger({ me, amount })) {
await zapUndo(this.signal) await zapUndo(this.signal)

View File

@ -22,14 +22,15 @@ import { DropdownItemUpVote } from './upvote'
import { useRoot } from './root' import { useRoot } from './root'
import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header' import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header'
import UserPopover from './user-popover' import UserPopover from './user-popover'
import { useItemContext } from './item' import { useQrPayment } from './payment'
import { useRetryCreateItem } from './use-item-submit'
export default function ItemInfo ({ export default function ItemInfo ({
item, full, commentsText = 'comments', item, full, commentsText = 'comments',
commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText, commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText,
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true
}) { }) {
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 const editThreshold = new Date(item.invoice?.confirmedAt ?? item.createdAt).getTime() + 10 * 60000
const me = useMe() const me = useMe()
const router = useRouter() const router = useRouter()
const [canEdit, setCanEdit] = const [canEdit, setCanEdit] =
@ -37,7 +38,7 @@ export default function ItemInfo ({
const [hasNewComments, setHasNewComments] = useState(false) const [hasNewComments, setHasNewComments] = useState(false)
const [meTotalSats, setMeTotalSats] = useState(0) const [meTotalSats, setMeTotalSats] = useState(0)
const root = useRoot() const root = useRoot()
const { pendingSats, pendingCommentSats, pendingDownSats } = useItemContext() const retryCreateItem = useRetryCreateItem({ id: item.id })
const sub = item?.sub || root?.sub const sub = item?.sub || root?.sub
useEffect(() => { useEffect(() => {
@ -47,8 +48,12 @@ export default function ItemInfo ({
}, [item]) }, [item])
useEffect(() => { useEffect(() => {
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0) + (pendingSats)) setCanEdit(item.mine && (Date.now() < editThreshold))
}, [item?.meSats, item?.meAnonSats, pendingSats]) }, [item.mine, editThreshold])
useEffect(() => {
if (item) setMeTotalSats(item.meSats || item.meAnonSats || 0)
}, [item?.meSats, item?.meAnonSats])
// territory founders can pin any post in their territory // territory founders can pin any post in their territory
// and OPs can pin any root reply in their post // and OPs can pin any root reply in their post
@ -58,7 +63,53 @@ export default function ItemInfo ({
const rootReply = item.path.split('.').length === 2 const rootReply = item.path.split('.').length === 2
const canPin = (isPost && mySub) || (myPost && rootReply) const canPin = (isPost && mySub) || (myPost && rootReply)
const downSats = item.meDontLikeSats + pendingDownSats const EditInfo = () => {
const waitForQrPayment = useQrPayment()
if (item.deletedAt) return null
let Component
let onClick
if (me && item.invoice?.actionState && item.invoice?.actionState !== 'PAID') {
if (item.invoice?.actionState === 'FAILED') {
Component = () => <span className='text-warning'>retry payment</span>
onClick = async () => await retryCreateItem({ variables: { invoiceId: parseInt(item.invoice?.id) } }).catch(console.error)
} else {
Component = () => (
<span
className='text-info'
>pending
</span>
)
onClick = () => waitForQrPayment({ id: item.invoice?.id }, null, { cancelOnClose: false }).catch(console.error)
}
} else if (canEdit) {
Component = () => (
<>
<span>{editText || 'edit'} </span>
<Countdown
date={editThreshold}
onComplete={() => {
setCanEdit(false)
}}
/>
</>)
onClick = () => onEdit ? onEdit() : router.push(`/items/${item.id}/edit`)
} else {
return null
}
return (
<>
<span> \ </span>
<span
className='text-reset pointer fw-bold'
onClick={onClick}
>
<Component />
</span>
</>
)
}
return ( return (
<div className={className || `${styles.other}`}> <div className={className || `${styles.other}`}>
@ -70,11 +121,11 @@ export default function ItemInfo ({
unitPlural: 'stackers' unitPlural: 'stackers'
})} ${item.mine })} ${item.mine
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post` ? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
: `(${numWithUnits(meTotalSats, { abbreviate: false })}${downSats : `(${numWithUnits(meTotalSats, { abbreviate: false })}${item.meDontLikeSats
? ` & ${numWithUnits(downSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}` ? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
: ''} from me)`} `} : ''} from me)`} `}
> >
{numWithUnits(item.sats + pendingSats)} {numWithUnits(item.sats)}
</span> </span>
<span> \ </span> <span> \ </span>
</>} </>}
@ -92,7 +143,7 @@ export default function ItemInfo ({
`/items/${item.id}?commentsViewedAt=${viewedAt}`, `/items/${item.id}?commentsViewedAt=${viewedAt}`,
`/items/${item.id}`) `/items/${item.id}`)
} }
}} title={numWithUnits(item.commentSats + pendingCommentSats)} className='text-reset position-relative' }} title={numWithUnits(item.commentSats)} className='text-reset position-relative'
> >
{numWithUnits(item.ncomments, { {numWithUnits(item.ncomments, {
abbreviate: false, abbreviate: false,
@ -144,72 +195,66 @@ export default function ItemInfo ({
<>{' '}<Badge className={styles.newComment} bg={null}>bot</Badge></> <>{' '}<Badge className={styles.newComment} bg={null}>bot</Badge></>
)} )}
{extraBadges} {extraBadges}
{canEdit && !item.deletedAt &&
<>
<span> \ </span>
<span
className='text-reset pointer'
onClick={() => onEdit ? onEdit() : router.push(`/items/${item.id}/edit`)}
>
<span>{editText || 'edit'} </span>
<Countdown
date={editThreshold}
onComplete={() => {
setCanEdit(false)
}}
/>
</span>
</>}
{ {
showActionDropdown && showActionDropdown &&
<ActionDropdown> <>
<CopyLinkDropdownItem item={item} /> <EditInfo />
{(item.parentId || item.text) && onQuoteReply && <ActionDropdown>
<Dropdown.Item onClick={onQuoteReply}>quote reply</Dropdown.Item>} <CopyLinkDropdownItem item={item} />
{me && <BookmarkDropdownItem item={item} />} {(item.parentId || item.text) && onQuoteReply &&
{me && <SubscribeDropdownItem item={item} />} <Dropdown.Item onClick={onQuoteReply}>quote reply</Dropdown.Item>}
{item.otsHash && {me && <BookmarkDropdownItem item={item} />}
<Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'> {me && <SubscribeDropdownItem item={item} />}
opentimestamp {item.otsHash &&
</Link>} <Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'>
{item?.noteId && ( opentimestamp
<Dropdown.Item onClick={() => window.open(`https://njump.me/${item.noteId}`, '_blank', 'noopener,noreferrer,nofollow')}> </Link>}
nostr note {item?.noteId && (
</Dropdown.Item> <Dropdown.Item onClick={() => window.open(`https://njump.me/${item.noteId}`, '_blank', 'noopener,noreferrer,nofollow')}>
)} nostr note
{item && item.mine && !item.noteId && !item.isJob && !item.parentId && </Dropdown.Item>
<CrosspostDropdownItem item={item} />} )}
{me && !item.position && {item && item.mine && !item.noteId && !item.isJob && !item.parentId &&
<CrosspostDropdownItem item={item} />}
{me && !item.position &&
!item.mine && !item.deletedAt && !item.mine && !item.deletedAt &&
(downSats > meTotalSats (item.meDontLikeSats > meTotalSats
? <DropdownItemUpVote item={item} /> ? <DropdownItemUpVote item={item} />
: <DontLikeThisDropdownItem item={item} />)} : <DontLikeThisDropdownItem item={item} />)}
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated && {me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
<> <>
<hr className='dropdown-divider' /> <hr className='dropdown-divider' />
<OutlawDropdownItem item={item} /> <OutlawDropdownItem item={item} />
</>} </>}
{me && !nested && !item.mine && sub && Number(me.id) !== Number(sub.userId) && {item.mine && item.invoice?.id &&
<> <>
<hr className='dropdown-divider' /> <hr className='dropdown-divider' />
<MuteSubDropdownItem item={item} sub={sub} /> <Link href={`/invoices/${item.invoice?.id}`} className='text-reset dropdown-item'>
</>} view invoice
{canPin && </Link>
<> </>}
<hr className='dropdown-divider' /> {me && !nested && !item.mine && sub && Number(me.id) !== Number(sub.userId) &&
<PinSubDropdownItem item={item} /> <>
</>} <hr className='dropdown-divider' />
{item.mine && !item.position && !item.deletedAt && !item.bio && <MuteSubDropdownItem item={item} sub={sub} />
<> </>}
<hr className='dropdown-divider' /> {canPin &&
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} /> <>
</>} <hr className='dropdown-divider' />
{me && !item.mine && <PinSubDropdownItem item={item} />
<> </>}
<hr className='dropdown-divider' /> {item.mine && !item.position && !item.deletedAt && !item.bio &&
<MuteDropdownItem user={item.user} /> <>
</>} <hr className='dropdown-divider' />
</ActionDropdown> <DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />
</>}
{me && !item.mine &&
<>
<hr className='dropdown-divider' />
<MuteDropdownItem user={item.user} />
</>}
</ActionDropdown>
</>
} }
{extraInfo} {extraInfo}
</div> </div>

View File

@ -1,7 +1,7 @@
import Link from 'next/link' import Link from 'next/link'
import styles from './item.module.css' import styles from './item.module.css'
import UpVote from './upvote' import UpVote from './upvote'
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react' import { useRef } from 'react'
import { USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants' import { USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants'
import Pin from '@/svgs/pushpin-fill.svg' import Pin from '@/svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace' import reactStringReplace from 'react-string-replace'
@ -45,60 +45,17 @@ export function SearchTitle ({ title }) {
}) })
} }
const ItemContext = createContext({ export default function Item ({
pendingSats: 0, item, rank, belowTitle, right, full, children, siblingComments,
setPendingSats: undefined, onQuoteReply, pinnable
pendingVote: undefined, }) {
setPendingVote: undefined,
pendingDownSats: 0,
setPendingDownSats: undefined
})
export const ItemContextProvider = ({ children }) => {
const parentCtx = useItemContext()
const [pendingSats, innerSetPendingSats] = useState(0)
const [pendingCommentSats, innerSetPendingCommentSats] = useState(0)
const [pendingVote, setPendingVote] = useState()
const [pendingDownSats, setPendingDownSats] = useState(0)
// cascade comment sats up to root context
const setPendingSats = useCallback((sats) => {
innerSetPendingSats(sats)
parentCtx?.setPendingCommentSats?.(sats)
}, [parentCtx?.setPendingCommentSats])
const setPendingCommentSats = useCallback((sats) => {
innerSetPendingCommentSats(sats)
parentCtx?.setPendingCommentSats?.(sats)
}, [parentCtx?.setPendingCommentSats])
const value = useMemo(() =>
({
pendingSats,
setPendingSats,
pendingCommentSats,
setPendingCommentSats,
pendingVote,
setPendingVote,
pendingDownSats,
setPendingDownSats
}),
[pendingSats, setPendingSats, pendingCommentSats, setPendingCommentSats, pendingVote, setPendingVote, pendingDownSats, setPendingDownSats])
return <ItemContext.Provider value={value}>{children}</ItemContext.Provider>
}
export const useItemContext = () => {
return useContext(ItemContext)
}
export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, onQuoteReply, pinnable }) {
const titleRef = useRef() const titleRef = useRef()
const router = useRouter() const router = useRouter()
const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL) const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL)
return ( return (
<ItemContextProvider> <>
{rank {rank
? ( ? (
<div className={styles.rank}> <div className={styles.rank}>
@ -106,7 +63,13 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
</div>) </div>)
: <div />} : <div />}
<div className={`${styles.item} ${siblingComments ? 'pt-3' : ''}`}> <div className={`${styles.item} ${siblingComments ? 'pt-3' : ''}`}>
<ZapIcon item={item} pinnable={pinnable} /> {item.position && (pinnable || !item.subName)
? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: Number(item.user?.id) === USER_ID.ad
? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} />}
<div className={styles.hunk}> <div className={styles.hunk}>
<div className={`${styles.main} flex-wrap`}> <div className={`${styles.main} flex-wrap`}>
<Link <Link
@ -150,7 +113,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
{children} {children}
</div> </div>
)} )}
</ItemContextProvider> </>
) )
} }
@ -228,21 +191,6 @@ export function ItemSkeleton ({ rank, children, showUpvote = true }) {
) )
} }
function ZapIcon ({ item, pinnable }) {
const { pendingSats, pendingDownSats } = useItemContext()
const meSats = item.meSats + pendingSats
const downSats = item.meDontLikeSats + pendingDownSats
return item.position && (pinnable || !item.subName)
? <Pin width={24} height={24} className={styles.pin} />
: downSats > meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: Number(item.user?.id) === USER_ID.ad
? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} />
}
function PollIndicator ({ item }) { function PollIndicator ({ item }) {
const hasExpiration = !!item.pollExpiresAt const hasExpiration = !!item.pollExpiresAt
const timeRemaining = timeLeft(new Date(item.pollExpiresAt)) const timeRemaining = timeLeft(new Date(item.pollExpiresAt))

View File

@ -5,21 +5,20 @@ import InputGroup from 'react-bootstrap/InputGroup'
import Image from 'react-bootstrap/Image' import Image from 'react-bootstrap/Image'
import BootstrapForm from 'react-bootstrap/Form' import BootstrapForm from 'react-bootstrap/Form'
import Alert from 'react-bootstrap/Alert' import Alert from 'react-bootstrap/Alert'
import { useCallback, useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Info from './info' import Info from './info'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import styles from '@/styles/post.module.css' import styles from '@/styles/post.module.css'
import { useLazyQuery, gql, useMutation } from '@apollo/client' import { useLazyQuery, gql } from '@apollo/client'
import { useRouter } from 'next/router'
import Link from 'next/link' import Link from 'next/link'
import { usePrice } from './price' import { usePrice } from './price'
import Avatar from './avatar' import Avatar from './avatar'
import { jobSchema } from '@/lib/validate' import { jobSchema } from '@/lib/validate'
import { MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants' import { MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants'
import { useToast } from './toast'
import { toastUpsertSuccessMessages } from '@/lib/form'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
import { useFormikContext } from 'formik' import { useFormikContext } from 'formik'
import { UPSERT_JOB } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'
function satsMin2Mo (minute) { function satsMin2Mo (minute) {
return minute * 30 * 24 * 60 return minute * 30 * 24 * 60
@ -40,53 +39,10 @@ function PriceHint ({ monthly }) {
// need to recent list items // need to recent list items
export default function JobForm ({ item, sub }) { export default function JobForm ({ item, sub }) {
const storageKeyPrefix = item ? undefined : `${sub.name}-job` const storageKeyPrefix = item ? undefined : `${sub.name}-job`
const router = useRouter()
const toaster = useToast()
const [logoId, setLogoId] = useState(item?.uploadId) const [logoId, setLogoId] = useState(item?.uploadId)
const [upsertJob] = useMutation(gql`
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, $location: String,
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int, $hash: String, $hmac: String) {
upsertJob(sub: $sub, id: $id, title: $title, company: $company,
location: $location, remote: $remote, text: $text,
url: $url, maxBid: $maxBid, status: $status, logo: $logo, hash: $hash, hmac: $hmac) {
id
deleteScheduledAt
reminderScheduledAt
}
}`
)
const onSubmit = useCallback( const extraValues = logoId ? { logo: Number(logoId) } : {}
async ({ maxBid, start, stop, ...values }) => { const onSubmit = useItemSubmit(UPSERT_JOB, { item, sub, extraValues })
let status
if (start) {
status = 'ACTIVE'
} else if (stop) {
status = 'STOPPED'
}
const { data, error } = await upsertJob({
variables: {
id: item?.id,
sub: item?.subName || sub?.name,
maxBid: Number(maxBid),
status,
logo: Number(logoId),
...values
}
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
await router.push(`/~${sub.name}/recent`)
}
toastUpsertSuccessMessages(toaster, data, 'upsertJob', !!item, values.text)
}, [upsertJob, router, logoId]
)
return ( return (
<> <>
@ -106,7 +62,6 @@ export default function JobForm ({ item, sub }) {
schema={jobSchema} schema={jobSchema}
storageKeyPrefix={storageKeyPrefix} storageKeyPrefix={storageKeyPrefix}
requireSession requireSession
prepaid
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<div className='form-group'> <div className='form-group'>

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect } from 'react'
import { Form, Input, MarkdownInput } from '@/components/form' import { Form, Input, MarkdownInput } from '@/components/form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { gql, useApolloClient, useLazyQuery } from '@apollo/client'
import Countdown from './countdown' import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form' import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { ITEM_FIELDS } from '@/fragments/items' import { ITEM_FIELDS } from '@/fragments/items'
@ -9,26 +9,23 @@ import Item from './item'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import { linkSchema } from '@/lib/validate' import { linkSchema } from '@/lib/validate'
import Moon from '@/svgs/moon-fill.svg' import Moon from '@/svgs/moon-fill.svg'
import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form' import { normalizeForwards } from '@/lib/form'
import { useToast } from './toast'
import { SubSelectInitial } from './sub-select' import { SubSelectInitial } from './sub-select'
import { MAX_TITLE_LENGTH } from '@/lib/constants' import { MAX_TITLE_LENGTH } from '@/lib/constants'
import useCrossposter from './use-crossposter'
import { useMe } from './me' import { useMe } from './me'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
import { UPSERT_LINK } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'
export function LinkForm ({ item, sub, editThreshold, children }) { export function LinkForm ({ item, sub, editThreshold, children }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const me = useMe()
const toaster = useToast()
const schema = linkSchema({ client, me, existingBoost: item?.boost }) const schema = linkSchema({ client, me, existingBoost: item?.boost })
// if Web Share Target API was used // if Web Share Target API was used
const shareUrl = router.query.url const shareUrl = router.query.url
const shareTitle = router.query.title const shareTitle = router.query.title
const crossposter = useCrossposter()
const [getPageTitleAndUnshorted, { data }] = useLazyQuery(gql` const [getPageTitleAndUnshorted, { data }] = useLazyQuery(gql`
query PageTitleAndUnshorted($url: String!) { query PageTitleAndUnshorted($url: String!) {
pageTitleAndUnshorted(url: $url) { pageTitleAndUnshorted(url: $url) {
@ -70,48 +67,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
} }
} }
const [upsertLink] = useMutation( const onSubmit = useItemSubmit(UPSERT_LINK, { item, sub })
gql`
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
upsertLink(sub: $sub, id: $id, title: $title, url: $url, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
id
deleteScheduledAt
reminderScheduledAt
}
}`
)
const onSubmit = useCallback(
async ({ boost, crosspost, title, ...values }) => {
const { data, error } = await upsertLink({
variables: {
sub: item?.subName || sub?.name,
id: item?.id,
boost: boost ? Number(boost) : undefined,
title: title.trim(),
...values,
forward: normalizeForwards(values.forward)
}
})
if (error) {
throw new Error({ message: error.toString() })
}
const linkId = data?.upsertLink?.id
if (crosspost && linkId) {
await crossposter(linkId)
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
toastUpsertSuccessMessages(toaster, data, 'upsertLink', !!item, values.text)
}, [upsertLink, router]
)
useEffect(() => { useEffect(() => {
if (data?.pageTitleAndUnshorted?.title) { if (data?.pageTitleAndUnshorted?.title) {
@ -143,7 +99,6 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}
prepaid
onSubmit={onSubmit} onSubmit={onSubmit}
storageKeyPrefix={storageKeyPrefix} storageKeyPrefix={storageKeyPrefix}
> >

View File

@ -47,9 +47,17 @@ export default function useModal () {
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
router.events.on('routeChangeStart', onClose) const maybeOnClose = () => {
return () => router.events.off('routeChangeStart', onClose) const content = getCurrentContent()
}, [router.events, onClose]) const { persistOnNavigate } = content?.options || {}
if (!persistOnNavigate) {
onClose()
}
}
router.events.on('routeChangeStart', maybeOnClose)
return () => router.events.off('routeChangeStart', maybeOnClose)
}, [router.events, onClose, getCurrentContent])
const modal = useMemo(() => { const modal = useMemo(() => {
if (modalStack.current.length === 0) { if (modalStack.current.length === 0) {

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useQuery } from '@apollo/client' import { gql, useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment' import Comment, { CommentSkeleton } from './comment'
import Item from './item' import Item from './item'
import ItemJob from './item-job' import ItemJob from './item-job'
@ -29,9 +29,13 @@ import { LongCountdown } from './countdown'
import { nextBillingWithGrace } from '@/lib/territory' import { nextBillingWithGrace } from '@/lib/territory'
import { commentSubTreeRootId } from '@/lib/item' import { commentSubTreeRootId } from '@/lib/item'
import LinkToContext from './link-to-context' import LinkToContext from './link-to-context'
import { Badge } from 'react-bootstrap' import { Badge, Button } from 'react-bootstrap'
import { Types as ClientTypes, ClientZap, ClientReply, ClientPollVote, ClientBounty, useClientNotifications } from './client-notifications' import { useAct } from './item-act'
import { ITEM_FULL } from '@/fragments/items' import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
import { usePollVote } from './poll'
import { paidActionCacheMods } from './use-paid-mutation'
import { useRetryCreateItem } from './use-item-submit'
import { payBountyCacheMods } from './pay-bounty'
function Notification ({ n, fresh }) { function Notification ({ n, fresh }) {
const type = n.__typename const type = n.__typename
@ -57,26 +61,12 @@ function Notification ({ n, fresh }) {
(type === 'TerritoryPost' && <TerritoryPost n={n} />) || (type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) || (type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
(type === 'Reminder' && <Reminder n={n} />) || (type === 'Reminder' && <Reminder n={n} />) ||
<ClientNotification n={n} /> (type === 'Invoicification' && <Invoicification n={n} />)
} }
</NotificationLayout> </NotificationLayout>
) )
} }
function ClientNotification ({ n }) {
// we need to resolve item id to item to show item for client notifications
const { data } = useQuery(ITEM_FULL, { variables: { id: n.itemId }, skip: !n.itemId })
const item = data?.item
const itemN = { item, ...n }
return (
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(n.__typename) && <ClientZap n={itemN} />) ||
([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(n.__typename) && <ClientReply n={itemN} />) ||
([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(n.__typename) && <ClientBounty n={itemN} />) ||
([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(n.__typename) && <ClientPollVote n={itemN} />)
)
}
function NotificationLayout ({ children, nid, href, as, fresh }) { function NotificationLayout ({ children, nid, href, as, fresh }) {
const router = useRouter() const router = useRouter()
if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div> if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div>
@ -111,10 +101,34 @@ const defaultOnClick = n => {
href += dayMonthYear(new Date(n.sortTime)) href += dayMonthYear(new Date(n.sortTime))
return { href } return { href }
} }
const itemLink = item => {
if (!item) return {}
if (item.title) {
return {
href: {
pathname: '/items/[id]',
query: { id: item.id }
},
as: `/items/${item.id}`
}
} else {
const rootId = commentSubTreeRootId(item)
return {
href: {
pathname: '/items/[id]',
query: { id: rootId, commentId: item.id }
},
as: `/items/${rootId}`
}
}
}
if (type === 'Revenue') return { href: `/~${n.subName}` } if (type === 'Revenue') return { href: `/~${n.subName}` }
if (type === 'SubStatus') return { href: `/~${n.sub.name}` } if (type === 'SubStatus') return { href: `/~${n.sub.name}` }
if (type === 'Invitification') return { href: '/invites' } if (type === 'Invitification') return { href: '/invites' }
if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` } if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` }
if (type === 'Invoicification') return itemLink(n.invoice.item)
if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` } if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` }
if (type === 'Referral') return { href: '/referrals/month' } if (type === 'Referral') return { href: '/referrals/month' }
if (type === 'Streak') return {} if (type === 'Streak') return {}
@ -123,24 +137,7 @@ const defaultOnClick = n => {
if (!n.item) return {} if (!n.item) return {}
// Votification, Mention, JobChanged, Reply all have item // Votification, Mention, JobChanged, Reply all have item
if (!n.item.title) { return itemLink(n.item)
const rootId = commentSubTreeRootId(n.item)
return {
href: {
pathname: '/items/[id]',
query: { id: rootId, commentId: n.item.id }
},
as: `/items/${rootId}`
}
} else {
return {
href: {
pathname: '/items/[id]',
query: { id: n.item.id }
},
as: `/items/${n.item.id}`
}
}
} }
function Streak ({ n }) { function Streak ({ n }) {
@ -300,6 +297,123 @@ function InvoicePaid ({ n }) {
) )
} }
function useActRetry ({ invoice }) {
const bountyCacheMods = invoice.item?.bounty ? payBountyCacheMods() : {}
return useAct({
query: RETRY_PAID_ACTION,
onPayError: (e, cache, { data }) => {
paidActionCacheMods?.onPayError?.(e, cache, { data })
bountyCacheMods?.onPayError?.(e, cache, { data })
},
onPaid: (cache, { data }) => {
paidActionCacheMods?.onPaid?.(cache, { data })
bountyCacheMods?.onPaid?.(cache, { data })
},
update: (cache, { data }) => {
const response = Object.values(data)[0]
if (!response?.invoice) return
cache.modify({
id: `ItemAct:${invoice.itemAct?.id}`,
fields: {
// this is a bit of a hack just to update the reference to the new invoice
invoice: () => cache.writeFragment({
id: `Invoice:${response.invoice.id}`,
fragment: gql`
fragment _ on Invoice {
bolt11
}
`,
data: { bolt11: response.invoice.bolt11 }
})
}
})
paidActionCacheMods?.update?.(cache, { data })
bountyCacheMods?.update?.(cache, { data })
}
})
}
function Invoicification ({ n: { invoice, sortTime } }) {
const actRetry = useActRetry({ invoice })
const retryCreateItem = useRetryCreateItem({ id: invoice.item?.id })
const retryPollVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: invoice.item?.id })
// XXX if we navigate to an invoice after it is retried in notifications
// the cache will clear invoice.item and will error on window.back
// alternatively, we could/should
// 1. update the notification cache to include the new invoice
// 2. make item has-many invoices
if (!invoice.item) return null
let retry
let actionString
let invoiceId
let invoiceActionState
const itemType = invoice.item.title ? 'post' : 'comment'
if (invoice.actionType === 'ITEM_CREATE') {
actionString = `${itemType} create `
retry = retryCreateItem;
({ id: invoiceId, actionState: invoiceActionState } = invoice.item.invoice)
} else if (invoice.actionType === 'POLL_VOTE') {
actionString = 'poll vote '
retry = retryPollVote
invoiceId = invoice.item.poll?.meInvoiceId
invoiceActionState = invoice.item.poll?.meInvoiceActionState
} else {
actionString = `${invoice.actionType === 'ZAP'
? invoice.item.root?.bounty ? 'bounty payment' : 'zap'
: 'downzap'} on ${itemType} `
retry = actRetry;
({ id: invoiceId, actionState: invoiceActionState } = invoice.itemAct.invoice)
}
let colorClass = 'text-info'
switch (invoiceActionState) {
case 'FAILED':
actionString += 'failed'
colorClass = 'text-warning'
break
case 'PAID':
actionString += 'paid'
colorClass = 'text-success'
break
default:
actionString += 'pending'
}
return (
<div className='px-2'>
<small className={`fw-bold ${colorClass} d-inline-flex align-items-center my-1`}>
{actionString}
<span className='ms-1 text-muted fw-light'> {numWithUnits(invoice.satsRequested)}</span>
<span className={invoiceActionState === 'FAILED' ? 'visible' : 'invisible'}>
<Button
size='sm' variant='outline-warning ms-2 border-1 rounded py-0'
style={{ '--bs-btn-hover-color': '#fff', '--bs-btn-active-color': '#fff' }}
onClick={() => {
retry({ variables: { invoiceId: parseInt(invoiceId) } }).catch(console.error)
}}
>
retry
</Button>
<span className='text-muted ms-2 fw-normal' suppressHydrationWarning>{timeSince(new Date(sortTime))}</span>
</span>
</small>
<div>
{invoice.item.title
? <Item item={invoice.item} />
: (
<div className='pb-2'>
<RootProvider root={invoice.item.root}>
<Comment item={invoice.item} noReply includeParent clickToContext />
</RootProvider>
</div>
)}
</div>
</div>
)
}
function WithdrawlPaid ({ n }) { function WithdrawlPaid ({ n }) {
return ( return (
<div className='fw-bold text-info ms-2 py-1'> <div className='fw-bold text-info ms-2 py-1'>
@ -404,16 +518,14 @@ function ItemMention ({ n }) {
<small className='fw-bold text-info ms-2'> <small className='fw-bold text-info ms-2'>
your item was mentioned in your item was mentioned in
</small> </small>
<div> {n.item?.title
{n.item?.title ? <div className='ps-2'><Item item={n.item} /></div>
? <Item item={n.item} /> : (
: ( <div className='pb-2'>
<div className='pb-2'> <RootProvider root={n.item.root}>
<RootProvider root={n.item.root}> <Comment item={n.item} noReply includeParent rootText='replying on:' clickToContext />
<Comment item={n.item} noReply includeParent rootText='replying on:' clickToContext /> </RootProvider>
</RootProvider> </div>)}
</div>)}
</div>
</> </>
) )
} }
@ -474,7 +586,7 @@ function TerritoryPost ({ n }) {
<small className='fw-bold text-info ms-2'> <small className='fw-bold text-info ms-2'>
new post in ~{n.item.sub.name} new post in ~{n.item.sub.name}
</small> </small>
<div> <div className='ps-2'>
<Item item={n.item} /> <Item item={n.item} />
</div> </div>
</> </>
@ -574,7 +686,6 @@ export default function Notifications ({ ssrData }) {
const { data, fetchMore } = useQuery(NOTIFICATIONS) const { data, fetchMore } = useQuery(NOTIFICATIONS)
const router = useRouter() const router = useRouter()
const dat = useData(data, ssrData) const dat = useData(data, ssrData)
const { notifications: clientNotifications } = useClientNotifications()
const { notifications, lastChecked, cursor } = useMemo(() => { const { notifications, lastChecked, cursor } = useMemo(() => {
if (!dat?.notifications) return {} if (!dat?.notifications) return {}
@ -602,12 +713,9 @@ export default function Notifications ({ ssrData }) {
if (!dat) return <CommentsFlatSkeleton /> if (!dat) return <CommentsFlatSkeleton />
const sorted = [...clientNotifications, ...notifications]
.sort((a, b) => new Date(b.sortTime).getTime() - new Date(a.sortTime).getTime())
return ( return (
<> <>
{sorted.map(n => {notifications.map(n =>
<Notification <Notification
n={n} key={nid(n)} n={n} key={nid(n)}
fresh={new Date(n.sortTime) > new Date(router?.query?.checkedAt ?? lastChecked)} fresh={new Date(n.sortTime) > new Date(router?.query?.checkedAt ?? lastChecked)}

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react' import React from 'react'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import styles from './pay-bounty.module.css' import styles from './pay-bounty.module.css'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
@ -6,23 +6,16 @@ import { useMe } from './me'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { useRoot } from './root' import { useRoot } from './root'
import { useAct, actUpdate } from './item-act' import { ActCanceledError, useAct } from './item-act'
import { InvoiceCanceledError, usePayment } from './payment' import { InvoiceCanceledError } from './payment'
import { useLightning } from './lightning' import { useLightning } from './lightning'
import { useToast } from './toast' import { useToast } from './toast'
import { Types as ClientNotification, useClientNotifications } from './client-notifications'
export default function PayBounty ({ children, item }) { export const payBountyCacheMods = {
const me = useMe() onPaid: (cache, { data }) => {
const showModal = useShowModal() const response = Object.values(data)[0]
const root = useRoot() if (!response?.result) return
const payment = usePayment() const { id, path } = response.result
const strike = useLightning()
const toaster = useToast()
const { notify, unnotify } = useClientNotifications()
const onUpdate = useCallback(onComplete => (cache, { data: { act: { id, path } } }) => {
// update root bounty status
const root = path.split('.')[0] const root = path.split('.')[0]
cache.modify({ cache.modify({
id: `Item:${root}`, id: `Item:${root}`,
@ -32,46 +25,48 @@ export default function PayBounty ({ children, item }) {
} }
} }
}) })
strike() },
onComplete() onPayError: (e, cache, { data }) => {
}, [strike]) const response = Object.values(data)[0]
if (!response?.result) return
const act = useAct() const { id, path } = response.result
const root = path.split('.')[0]
const handlePayBounty = async onComplete => { cache.modify({
const sats = root.bounty id: `Item:${root}`,
const variables = { id: item.id, sats, act: 'TIP', path: item.path } fields: {
const notifyProps = { itemId: item.id, sats } bountyPaidTo (existingPaidTo = []) {
const optimisticResponse = { act: { ...variables, path: item.path } } return (existingPaidTo || []).filter(i => i !== Number(id))
}
let cancel, nid
try {
if (me) {
nid = notify(ClientNotification.Bounty.PENDING, notifyProps)
} }
})
}
}
let hash, hmac; export default function PayBounty ({ children, item }) {
[{ hash, hmac }, cancel] = await payment.request(sats) const me = useMe()
const showModal = useShowModal()
const root = useRoot()
const strike = useLightning()
const toaster = useToast()
const variables = { id: item.id, sats: root.bounty, act: 'TIP' }
const act = useAct({
variables,
optimisticResponse: { act: { result: { ...variables, path: item.path } } },
...payBountyCacheMods
})
await act({ const handlePayBounty = async onCompleted => {
variables: { hash, hmac, ...variables }, try {
optimisticResponse, strike()
update: actUpdate({ me, onUpdate: onUpdate(onComplete) }) await act({ onCompleted })
})
} catch (error) { } catch (error) {
if (error instanceof InvoiceCanceledError) { if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
return return
} }
const reason = error?.message || error?.toString?.() const reason = error?.message || error?.toString?.()
if (me) {
notify(ClientNotification.Bounty.ERROR, { ...notifyProps, reason }) toaster.danger('pay bounty failed: ' + reason)
} else {
toaster.danger('pay bounty failed: ' + reason)
}
cancel?.()
} finally {
if (nid) unnotify(nid)
} }
} }

View File

@ -1,4 +1,4 @@
import { useCallback } from 'react' import { useCallback, useMemo } from 'react'
import { useMe } from './me' import { useMe } from './me'
import { gql, useApolloClient, useMutation } from '@apollo/client' import { gql, useApolloClient, useMutation } from '@apollo/client'
import { useWebLN } from './webln' import { useWebLN } from './webln'
@ -29,7 +29,7 @@ export class InvoiceExpiredError extends Error {
} }
} }
const useInvoice = () => { export const useInvoice = () => {
const client = useApolloClient() const client = useApolloClient()
const [createInvoice] = useMutation(gql` const [createInvoice] = useMutation(gql`
@ -60,53 +60,75 @@ const useInvoice = () => {
return invoice return invoice
}, [createInvoice]) }, [createInvoice])
const isPaid = useCallback(async id => { const isInvoice = useCallback(async ({ id }, that) => {
const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'no-cache', variables: { id } }) const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'network-only', variables: { id } })
if (error) { if (error) {
throw error throw error
} }
const { hash, isHeld, satsReceived, cancelled } = data.invoice const { hash, cancelled } = data.invoice
// if we're polling for invoices, we're using JIT invoices so isHeld must be set
if (isHeld && satsReceived) {
return true
}
if (cancelled) { if (cancelled) {
throw new InvoiceCanceledError(hash) throw new InvoiceCanceledError(hash)
} }
return false
return that(data.invoice)
}, [client]) }, [client])
const waitUntilPaid = useCallback(async id => { const waitController = useMemo(() => {
return await new Promise((resolve, reject) => { const controller = new AbortController()
const interval = setInterval(async () => { const signal = controller.signal
try { controller.wait = async ({ id }, waitFor = inv => (inv.satsReceived > 0)) => {
const paid = await isPaid(id) return await new Promise((resolve, reject) => {
if (paid) { const interval = setInterval(async () => {
resolve() try {
const paid = await isInvoice({ id }, waitFor)
if (paid) {
resolve()
clearInterval(interval)
signal.removeEventListener('abort', abort)
} else {
console.info(`invoice #${id}: waiting for payment ...`)
}
} catch (err) {
reject(err)
clearInterval(interval) clearInterval(interval)
signal.removeEventListener('abort', abort)
} }
} catch (err) { }, FAST_POLL_INTERVAL)
reject(err)
const abort = () => {
console.info(`invoice #${id}: stopped waiting`)
resolve()
clearInterval(interval) clearInterval(interval)
signal.removeEventListener('abort', abort)
} }
}, FAST_POLL_INTERVAL) signal.addEventListener('abort', abort)
}) })
}, [isPaid]) }
controller.stop = () => controller.abort()
return controller
}, [isInvoice])
const cancel = useCallback(async ({ hash, hmac }) => { const cancel = useCallback(async ({ hash, hmac }) => {
if (!hash || !hmac) {
throw new Error('missing hash or hmac')
}
console.log('canceling invoice:', hash)
const inv = await cancelInvoice({ variables: { hash, hmac } }) const inv = await cancelInvoice({ variables: { hash, hmac } })
console.log('invoice canceled:', hash)
return inv return inv
}, [cancelInvoice]) }, [cancelInvoice])
return { create, isPaid, waitUntilPaid, cancel } return { create, waitUntilPaid: waitController.wait, stopWaiting: waitController.stop, cancel }
} }
const useWebLnPayment = () => { export const useWebLnPayment = () => {
const invoice = useInvoice() const invoice = useInvoice()
const provider = useWebLN() const provider = useWebLN()
const waitForWebLnPayment = useCallback(async ({ id, bolt11 }) => { const waitForWebLnPayment = useCallback(async ({ id, bolt11 }, waitFor) => {
if (!provider) { if (!provider) {
throw new WebLnNotEnabledError() throw new WebLnNotEnabledError()
} }
@ -119,42 +141,56 @@ const useWebLnPayment = () => {
// since they only get resolved after settlement which can't happen here // since they only get resolved after settlement which can't happen here
.then(resolve) .then(resolve)
.catch(reject) .catch(reject)
invoice.waitUntilPaid(id) invoice.waitUntilPaid({ id }, waitFor)
.then(resolve) .then(resolve)
.catch(reject) .catch(reject)
}) })
} catch (err) { } catch (err) {
console.error('WebLN payment failed:', err) console.error('WebLN payment failed:', err)
throw err throw err
} finally {
invoice.stopWaiting()
} }
}, [provider, invoice]) }, [provider, invoice])
return waitForWebLnPayment return waitForWebLnPayment
} }
const useQrPayment = () => { export const useQrPayment = () => {
const invoice = useInvoice() const invoice = useInvoice()
const showModal = useShowModal() const showModal = useShowModal()
const waitForQrPayment = useCallback(async (inv, webLnError) => { const waitForQrPayment = useCallback(async (inv, webLnError,
{
keepOpen = true,
cancelOnClose = true,
persistOnNavigate = false,
waitFor = inv => inv?.satsReceived > 0
} = {}
) => {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
let paid let paid
const cancelAndReject = async (onClose) => { const cancelAndReject = async (onClose) => {
if (paid) return if (!paid && cancelOnClose) {
await invoice.cancel(inv) await invoice.cancel(inv).catch(console.error)
reject(new InvoiceCanceledError(inv.hash)) reject(new InvoiceCanceledError(inv?.hash))
}
resolve()
} }
showModal(onClose => showModal(onClose =>
<Invoice <Invoice
invoice={inv} id={inv.id}
modal modal
description
status='loading'
successVerb='received' successVerb='received'
webLn={false} webLn={false}
webLnError={webLnError} webLnError={webLnError}
waitFor={waitFor}
onPayment={() => { paid = true; onClose(); resolve() }} onPayment={() => { paid = true; onClose(); resolve() }}
poll poll
/>, />,
{ keepOpen: true, onClose: cancelAndReject }) { keepOpen, persistOnNavigate, onClose: cancelAndReject })
}) })
}, [invoice]) }, [invoice])

View File

@ -1,74 +1,23 @@
import { DateTimeInput, Form, Input, MarkdownInput, VariableInput } from '@/components/form' import { DateTimeInput, Form, Input, MarkdownInput, VariableInput } from '@/components/form'
import { useRouter } from 'next/router' import { useApolloClient } from '@apollo/client'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import Countdown from './countdown' import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form' import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '@/lib/constants' import { MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '@/lib/constants'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import { pollSchema } from '@/lib/validate' import { pollSchema } from '@/lib/validate'
import { SubSelectInitial } from './sub-select' import { SubSelectInitial } from './sub-select'
import { useCallback } from 'react' import { normalizeForwards } from '@/lib/form'
import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
import useCrossposter from './use-crossposter'
import { useMe } from './me' import { useMe } from './me'
import { useToast } from './toast'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
import { UPSERT_POLL } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'
export function PollForm ({ item, sub, editThreshold, children }) { export function PollForm ({ item, sub, editThreshold, children }) {
const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const me = useMe()
const toaster = useToast()
const schema = pollSchema({ client, me, existingBoost: item?.boost }) const schema = pollSchema({ client, me, existingBoost: item?.boost })
const crossposter = useCrossposter() const onSubmit = useItemSubmit(UPSERT_POLL, { item, sub })
const [upsertPoll] = useMutation(
gql`
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
$options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String, $pollExpiresAt: Date) {
upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
options: $options, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac, pollExpiresAt: $pollExpiresAt) {
id
deleteScheduledAt
reminderScheduledAt
}
}`
)
const onSubmit = useCallback(
async ({ boost, title, options, crosspost, ...values }) => {
const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
const { data, error } = await upsertPoll({
variables: {
id: item?.id,
sub: item?.subName || sub?.name,
boost: boost ? Number(boost) : undefined,
title: title.trim(),
options: optionsFiltered,
...values,
forward: normalizeForwards(values.forward)
}
})
if (error) {
throw new Error({ message: error.toString() })
}
const pollId = data?.upsertPoll?.id
if (crosspost && pollId) {
await crossposter(pollId)
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
toastUpsertSuccessMessages(toaster, data, 'upsertPoll', !!item, values.text)
}, [upsertPoll, router]
)
const initialOptions = item?.poll?.options.map(i => i.option) const initialOptions = item?.poll?.options.map(i => i.option)
@ -86,7 +35,6 @@ export function PollForm ({ item, sub, editThreshold, children }) {
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}
prepaid
onSubmit={onSubmit} onSubmit={onSubmit}
storageKeyPrefix={storageKeyPrefix} storageKeyPrefix={storageKeyPrefix}
> >

View File

@ -1,4 +1,3 @@
import { gql, useMutation } from '@apollo/client'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import { fixedDecimal, numWithUnits } from '@/lib/format' import { fixedDecimal, numWithUnits } from '@/lib/format'
import { timeLeft } from '@/lib/time' import { timeLeft } from '@/lib/time'
@ -6,47 +5,17 @@ import { useMe } from './me'
import styles from './poll.module.css' import styles from './poll.module.css'
import { signIn } from 'next-auth/react' import { signIn } from 'next-auth/react'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
import { POLL_COST } from '@/lib/constants' import { InvoiceCanceledError, useQrPayment } from './payment'
import { InvoiceCanceledError, usePayment } from './payment'
import { useToast } from './toast' import { useToast } from './toast'
import { Types as ClientNotification, useClientNotifications } from './client-notifications' import { usePaidMutation } from './use-paid-mutation'
import { useItemContext } from './item' import { POLL_VOTE, RETRY_PAID_ACTION } from '@/fragments/paidAction'
export default function Poll ({ item }) { export default function Poll ({ item }) {
const me = useMe() const me = useMe()
const POLL_VOTE_MUTATION = gql` const pollVote = usePollVote({ query: POLL_VOTE, itemId: item.id })
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
pollVote(id: $id, hash: $hash, hmac: $hmac)
}`
const [pollVote] = useMutation(POLL_VOTE_MUTATION)
const toaster = useToast() const toaster = useToast()
const { notify, unnotify } = useClientNotifications()
const { pendingVote, setPendingVote } = useItemContext()
const update = (cache, { data: { pollVote } }) => {
cache.modify({
id: `Item:${item.id}`,
fields: {
poll (existingPoll) {
const poll = { ...existingPoll }
poll.meVoted = true
poll.count += 1
return poll
}
}
})
cache.modify({
id: `PollOption:${pollVote}`,
fields: {
count (existingCount) {
return existingCount + 1
}
}
})
}
const PollButton = ({ v }) => { const PollButton = ({ v }) => {
const payment = usePayment()
return ( return (
<ActionTooltip placement='left' notForm overlayText='1 sat'> <ActionTooltip placement='left' notForm overlayText='1 sat'>
<Button <Button
@ -54,36 +23,20 @@ export default function Poll ({ item }) {
onClick={me onClick={me
? async () => { ? async () => {
const variables = { id: v.id } const variables = { id: v.id }
const notifyProps = { itemId: item.id } const optimisticResponse = { pollVote: { result: { id: v.id } } }
const optimisticResponse = { pollVote: v.id }
let cancel, nid
try { try {
setPendingVote(v.id) await pollVote({
variables,
if (me) { optimisticResponse
nid = notify(ClientNotification.PollVote.PENDING, notifyProps) })
}
let hash, hmac;
[{ hash, hmac }, cancel] = await payment.request(item.pollCost || POLL_COST)
await pollVote({ variables: { hash, hmac, ...variables }, optimisticResponse, update })
} catch (error) { } catch (error) {
if (error instanceof InvoiceCanceledError) { if (error instanceof InvoiceCanceledError) {
return return
} }
const reason = error?.message || error?.toString?.() const reason = error?.message || error?.toString?.()
if (me) {
notify(ClientNotification.PollVote.ERROR, { ...notifyProps, reason })
} else {
toaster.danger('poll vote failed: ' + reason)
}
cancel?.() toaster.danger('poll vote failed: ' + reason)
} finally {
setPendingVote(undefined)
if (nid) unnotify(nid)
} }
} }
: signIn} : signIn}
@ -94,11 +47,36 @@ export default function Poll ({ item }) {
) )
} }
const RetryVote = () => {
const retryVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: item.id })
const waitForQrPayment = useQrPayment()
if (item.poll.meInvoiceActionState === 'PENDING') {
return (
<span
className='ms-2 fw-bold text-info pointer'
onClick={() => waitForQrPayment(
{ id: parseInt(item.poll.meInvoiceId) }, null, { cancelOnClose: false }).catch(console.error)}
>vote pending
</span>
)
}
return (
<span
className='ms-2 fw-bold text-warning pointer'
onClick={() => retryVote({ variables: { invoiceId: parseInt(item.poll.meInvoiceId) } })}
>
retry vote
</span>
)
}
const hasExpiration = !!item.pollExpiresAt const hasExpiration = !!item.pollExpiresAt
const timeRemaining = timeLeft(new Date(item.pollExpiresAt)) const timeRemaining = timeLeft(new Date(item.pollExpiresAt))
const mine = item.user.id === me?.id const mine = item.user.id === me?.id
const showPollButton = (!hasExpiration || timeRemaining) && !item.poll.meVoted && !mine && !pendingVote const meVotePending = item.poll.meInvoiceActionState && item.poll.meInvoiceActionState !== 'PAID'
const pollCount = item.poll.count + (pendingVote ? 1 : 0) const showPollButton = me && (!hasExpiration || timeRemaining) && !item.poll.meVoted && !meVotePending && !mine
const pollCount = item.poll.count
return ( return (
<div className={styles.pollBox}> <div className={styles.pollBox}>
{item.poll.options.map(v => {item.poll.options.map(v =>
@ -107,12 +85,13 @@ export default function Poll ({ item }) {
: <PollResult : <PollResult
key={v.id} v={v} key={v.id} v={v}
progress={pollCount progress={pollCount
? fixedDecimal((v.count + (pendingVote === v.id ? 1 : 0)) * 100 / pollCount, 1) ? fixedDecimal((v.count) * 100 / pollCount, 1)
: 0} : 0}
/>)} />)}
<div className='text-muted mt-1'> <div className='text-muted mt-1'>
{numWithUnits(pollCount, { unitSingular: 'vote', unitPlural: 'votes' })} {numWithUnits(item.poll.count, { unitSingular: 'vote', unitPlural: 'votes' })}
{hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`} {hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`}
{!showPollButton && meVotePending && <RetryVote />}
</div> </div>
</div> </div>
) )
@ -127,3 +106,89 @@ function PollResult ({ v, progress }) {
</div> </div>
) )
} }
export function usePollVote ({ query = POLL_VOTE, itemId }) {
const update = (cache, { data }) => {
// the mutation name varies for optimistic retries
const response = Object.values(data)[0]
if (!response) return
const { result, invoice } = response
const { id } = result
cache.modify({
id: `Item:${itemId}`,
fields: {
poll (existingPoll) {
const poll = { ...existingPoll }
poll.meVoted = true
if (invoice) {
poll.meInvoiceActionState = 'PENDING'
poll.meInvoiceId = invoice.id
}
poll.count += 1
return poll
}
}
})
cache.modify({
id: `PollOption:${id}`,
fields: {
count (existingCount) {
return existingCount + 1
}
}
})
}
const onPayError = (e, cache, { data }) => {
// the mutation name varies for optimistic retries
const response = Object.values(data)[0]
if (!response) return
const { result, invoice } = response
const { id } = result
cache.modify({
id: `Item:${itemId}`,
fields: {
poll (existingPoll) {
const poll = { ...existingPoll }
poll.meVoted = false
if (invoice) {
poll.meInvoiceActionState = 'FAILED'
poll.meInvoiceId = invoice?.id
}
poll.count -= 1
return poll
}
}
})
cache.modify({
id: `PollOption:${id}`,
fields: {
count (existingCount) {
return existingCount - 1
}
}
})
}
const onPaid = (cache, { data }) => {
// the mutation name varies for optimistic retries
const response = Object.values(data)[0]
if (!response?.invoice) return
const { invoice } = response
cache.modify({
id: `Item:${itemId}`,
fields: {
poll (existingPoll) {
const poll = { ...existingPoll }
poll.meVoted = true
poll.meInvoiceActionState = 'PAID'
poll.meInvoiceId = invoice.id
return poll
}
}
})
}
const [pollVote] = usePaidMutation(query, { update, onPayError, onPaid })
return pollVote
}

View File

@ -3,7 +3,6 @@ import { CopyInput, InputSkeleton } from './form'
import InvoiceStatus from './invoice-status' import InvoiceStatus from './invoice-status'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useWebLN } from './webln' import { useWebLN } from './webln'
import SimpleCountdown from './countdown'
import Bolt11Info from './bolt11-info' import Bolt11Info from './bolt11-info'
export default function Qr ({ asIs, value, webLn, statusVariant, description, status }) { export default function Qr ({ asIs, value, webLn, statusVariant, description, status }) {
@ -48,9 +47,6 @@ export function QrSkeleton ({ status, description, bolt11Info }) {
<InputSkeleton /> <InputSkeleton />
</div> </div>
<InvoiceStatus variant='default' status={status} /> <InvoiceStatus variant='default' status={status} />
<div className='text-muted text-center invisible'>
<SimpleCountdown date={Date.now()} />
</div>
{bolt11Info && <Bolt11Info />} {bolt11Info && <Bolt11Info />}
</> </>
) )

View File

@ -1,5 +1,4 @@
import { Form, MarkdownInput } from '@/components/form' import { Form, MarkdownInput } from '@/components/form'
import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css' import styles from './reply.module.css'
import { COMMENTS } from '@/fragments/comments' import { COMMENTS } from '@/fragments/comments'
import { useMe } from './me' import { useMe } from './me'
@ -8,13 +7,13 @@ import Link from 'next/link'
import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button' import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
import { commentsViewedAfterComment } from '@/lib/new-comments' import { commentsViewedAfterComment } from '@/lib/new-comments'
import { commentSchema } from '@/lib/validate' import { commentSchema } from '@/lib/validate'
import { useToast } from './toast'
import { toastUpsertSuccessMessages } from '@/lib/form'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { useRoot } from './root' import { useRoot } from './root'
import { commentSubTreeRootId } from '@/lib/item' import { commentSubTreeRootId } from '@/lib/item'
import { CREATE_COMMENT } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'
export function ReplyOnAnotherPage ({ item }) { export function ReplyOnAnotherPage ({ item }) {
const rootId = commentSubTreeRootId(item) const rootId = commentSubTreeRootId(item)
@ -44,7 +43,6 @@ export default forwardRef(function Reply ({
const me = useMe() const me = useMe()
const parentId = item.id const parentId = item.id
const replyInput = useRef(null) const replyInput = useRef(null)
const toaster = useToast()
const showModal = useShowModal() const showModal = useShowModal()
const root = useRoot() const root = useRoot()
const sub = item?.sub || root?.sub const sub = item?.sub || root?.sub
@ -55,26 +53,18 @@ export default forwardRef(function Reply ({
} }
}, [replyOpen, quote, parentId]) }, [replyOpen, quote, parentId])
const [upsertComment] = useMutation( const onSubmit = useItemSubmit(CREATE_COMMENT, {
gql` extraValues: { parentId },
${COMMENTS} paidMutationOptions: {
mutation upsertComment($text: String!, $parentId: ID!, $hash: String, $hmac: String) { update (cache, { data: { upsertComment: { result, invoice } } }) {
upsertComment(text: $text, parentId: $parentId, hash: $hash, hmac: $hmac) { if (!result) return
...CommentFields
deleteScheduledAt
reminderScheduledAt
comments {
...CommentsRecursive
}
}
}`, {
update (cache, { data: { upsertComment } }) {
cache.modify({ cache.modify({
id: `Item:${parentId}`, id: `Item:${parentId}`,
fields: { fields: {
comments (existingCommentRefs = []) { comments (existingCommentRefs = []) {
const newCommentRef = cache.writeFragment({ const newCommentRef = cache.writeFragment({
data: upsertComment, data: result,
fragment: COMMENTS, fragment: COMMENTS,
fragmentName: 'CommentsRecursive' fragmentName: 'CommentsRecursive'
}) })
@ -100,17 +90,15 @@ export default forwardRef(function Reply ({
// so that we don't see indicator for our own comments, we record this comments as the latest time // so that we don't see indicator for our own comments, we record this comments as the latest time
// but we also have record num comments, in case someone else commented when we did // but we also have record num comments, in case someone else commented when we did
const root = ancestors[0] const root = ancestors[0]
commentsViewedAfterComment(root, upsertComment.createdAt) commentsViewedAfterComment(root, result.createdAt)
} }
} },
) onSuccessfulSubmit: (data, { resetForm }) => {
resetForm({ text: '' })
const onSubmit = useCallback(async ({ amount, hash, hmac, ...values }, { resetForm }) => { setReply(replyOpen || false)
const { data } = await upsertComment({ variables: { parentId, hash, hmac, ...values } }) },
toastUpsertSuccessMessages(toaster, data, 'upsertComment', false, values.text) navigateOnSubmit: false
resetForm({ text: '' }) })
setReply(replyOpen || false)
}, [upsertComment, setReply, parentId])
useEffect(() => { useEffect(() => {
if (replyInput.current && reply && !replyOpen) replyInput.current.focus() if (replyInput.current && reply && !replyOpen) replyInput.current.focus()
@ -174,7 +162,6 @@ export default forwardRef(function Reply ({
text: '' text: ''
}} }}
schema={commentSchema} schema={commentSchema}
prepaid
onSubmit={onSubmit} onSubmit={onSubmit}
storageKeyPrefix={`reply-${parentId}`} storageKeyPrefix={`reply-${parentId}`}
> >

View File

@ -2,7 +2,7 @@ import AccordianItem from './accordian-item'
import { Col, InputGroup, Row, Form as BootstrapForm, Badge } from 'react-bootstrap' import { Col, InputGroup, Row, Form as BootstrapForm, Badge } from 'react-bootstrap'
import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form' import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form'
import FeeButton, { FeeButtonProvider } from './fee-button' import FeeButton, { FeeButtonProvider } from './fee-button'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { gql, useApolloClient, useLazyQuery } from '@apollo/client'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { MAX_TERRITORY_DESC_LENGTH, POST_TYPES, TERRITORY_BILLING_OPTIONS, TERRITORY_PERIOD_COST } from '@/lib/constants' import { MAX_TERRITORY_DESC_LENGTH, POST_TYPES, TERRITORY_BILLING_OPTIONS, TERRITORY_PERIOD_COST } from '@/lib/constants'
@ -12,35 +12,15 @@ import Info from './info'
import { abbrNum } from '@/lib/format' import { abbrNum } from '@/lib/format'
import { purchasedType } from '@/lib/territory' import { purchasedType } from '@/lib/territory'
import { SUB } from '@/fragments/subs' import { SUB } from '@/fragments/subs'
import { usePaidMutation } from './use-paid-mutation'
import { UNARCHIVE_TERRITORY, UPSERT_SUB } from '@/fragments/paidAction'
export default function TerritoryForm ({ sub }) { export default function TerritoryForm ({ sub }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const me = useMe()
const [upsertSub] = useMutation( const [upsertSub] = usePaidMutation(UPSERT_SUB)
gql` const [unarchiveTerritory] = usePaidMutation(UNARCHIVE_TERRITORY)
mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String, $nsfw: Boolean!) {
upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac, nsfw: $nsfw) {
name
}
}`
)
const [unarchiveTerritory] = useMutation(
gql`
mutation unarchiveTerritory($name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String, $nsfw: Boolean!) {
unarchiveTerritory(name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac, nsfw: $nsfw) {
name
}
}`
)
const schema = territorySchema({ client, me, sub }) const schema = territorySchema({ client, me, sub })
@ -56,22 +36,28 @@ export default function TerritoryForm ({ sub }) {
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ ...variables }) => { async ({ ...variables }) => {
const { error } = archived const { error, payError } = archived
? await unarchiveTerritory({ variables }) ? await unarchiveTerritory({ variables })
: await upsertSub({ variables: { oldName: sub?.name, ...variables } }) : await upsertSub({ variables: { oldName: sub?.name, ...variables } })
if (error) { if (error) throw error
throw new Error({ message: error.toString() }) if (payError) throw new Error('payment required')
}
// modify graphql cache to include new sub // modify graphql cache to include new sub
client.cache.modify({ client.cache.modify({
fields: { fields: {
subs (existing = []) { subs (existing = [], { readField }) {
const filtered = existing.filter(s => s.name !== variables.name && s.name !== sub?.name) const newSubRef = client.cache.writeFragment({
return [ data: { __typename: 'Sub', name: variables.name },
...filtered, fragment: gql`
{ __typename: 'Sub', name: variables.name }] fragment SubSubmitFragment on Sub {
name
}`
})
if (existing.some(ref => readField('name', ref) === variables.name)) {
return existing
}
return [...existing, newSubRef]
} }
} }
}) })
@ -112,7 +98,6 @@ export default function TerritoryForm ({ sub }) {
nsfw: sub?.nsfw || false nsfw: sub?.nsfw || false
}} }}
schema={schema} schema={schema}
prepaid
onSubmit={onSubmit} onSubmit={onSubmit}
className='mb-5' className='mb-5'
storageKeyPrefix={sub ? undefined : 'territory'} storageKeyPrefix={sub ? undefined : 'territory'}

View File

@ -6,24 +6,24 @@ import { Form } from './form'
import { timeSince } from '@/lib/time' import { timeSince } from '@/lib/time'
import { LongCountdown } from './countdown' import { LongCountdown } from './countdown'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useApolloClient, useMutation } from '@apollo/client' import { useApolloClient } from '@apollo/client'
import { SUB_PAY } from '@/fragments/subs'
import { nextBillingWithGrace } from '@/lib/territory' import { nextBillingWithGrace } from '@/lib/territory'
import { usePaidMutation } from './use-paid-mutation'
import { SUB_PAY } from '@/fragments/paidAction'
export default function TerritoryPaymentDue ({ sub }) { export default function TerritoryPaymentDue ({ sub }) {
const me = useMe() const me = useMe()
const client = useApolloClient() const client = useApolloClient()
const [paySub] = useMutation(SUB_PAY) const [paySub] = usePaidMutation(SUB_PAY)
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ ...variables }) => { async ({ ...variables }) => {
const { error } = await paySub({ const { error, payError } = await paySub({
variables variables
}) })
if (error) { if (error) throw error
throw new Error({ message: error.toString() }) if (payError) throw new Error('payment required')
}
}, [client, paySub]) }, [client, paySub])
if (!sub || sub.userId !== Number(me?.id) || sub.status === 'ACTIVE') return null if (!sub || sub.userId !== Number(me?.id) || sub.status === 'ACTIVE') return null
@ -56,7 +56,6 @@ export default function TerritoryPaymentDue ({ sub }) {
<FeeButtonProvider baseLineItems={{ territory: TERRITORY_BILLING_OPTIONS('one')[sub.billingType.toLowerCase()] }}> <FeeButtonProvider baseLineItems={{ territory: TERRITORY_BILLING_OPTIONS('one')[sub.billingType.toLowerCase()] }}>
<Form <Form
prepaid
initial={{ initial={{
name: sub.name name: sub.name
}} }}

View File

@ -7,6 +7,7 @@
color: #fff; color: #fff;
border-width: 1px; border-width: 1px;
border-style: solid; border-style: solid;
text-overflow: ellipsis;
} }
.success { .success {

View File

@ -12,8 +12,6 @@ import Popover from 'react-bootstrap/Popover'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import { Dropdown } from 'react-bootstrap' import { Dropdown } from 'react-bootstrap'
import { useLightning } from './lightning'
import { useItemContext } from './item'
const UpvotePopover = ({ target, show, handleClose }) => { const UpvotePopover = ({ target, show, handleClose }) => {
const me = useMe() const me = useMe()
@ -56,23 +54,12 @@ const TipPopover = ({ target, show, handleClose }) => (
export function DropdownItemUpVote ({ item }) { export function DropdownItemUpVote ({ item }) {
const showModal = useShowModal() const showModal = useShowModal()
const { setPendingSats } = useItemContext()
const strike = useLightning()
const optimisticUpdate = useCallback((sats, { onClose } = {}) => {
setPendingSats(pendingSats => pendingSats + sats)
strike()
onClose?.()
return () => {
setPendingSats(pendingSats => pendingSats - sats)
}
}, [])
return ( return (
<Dropdown.Item <Dropdown.Item
onClick={async () => { onClick={async () => {
showModal(onClose => showModal(onClose =>
<ItemAct onClose={onClose} item={item} optimisticUpdate={optimisticUpdate} />) <ItemAct onClose={onClose} item={item} />)
}} }}
> >
<span className='text-success'>zap</span> <span className='text-success'>zap</span>
@ -109,10 +96,9 @@ export default function UpVote ({ item, className }) {
setWalkthrough(upvotePopover: $upvotePopover, tipPopover: $tipPopover) setWalkthrough(upvotePopover: $upvotePopover, tipPopover: $tipPopover)
}` }`
) )
const strike = useLightning()
const [controller, setController] = useState() const [controller, setController] = useState(null)
const { pendingSats, setPendingSats } = useItemContext() const [pending, setPending] = useState(false)
const pending = controller?.started && !controller.done
const setVoteShow = useCallback((yes) => { const setVoteShow = useCallback((yes) => {
if (!me) return if (!me) return
@ -148,7 +134,7 @@ export default function UpVote ({ item, className }) {
[item?.mine, item?.meForward, item?.deletedAt]) [item?.mine, item?.meForward, item?.deletedAt])
const [meSats, overlayText, color, nextColor] = useMemo(() => { const [meSats, overlayText, color, nextColor] = useMemo(() => {
const meSats = (item?.meSats || item?.meAnonSats || 0) + pendingSats const meSats = (item?.meSats || item?.meAnonSats || 0)
// what should our next tip be? // what should our next tip be?
const sats = nextTip(meSats, { ...me?.privates }) const sats = nextTip(meSats, { ...me?.privates })
@ -156,16 +142,7 @@ export default function UpVote ({ item, className }) {
return [ return [
meSats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it', meSats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it',
getColor(meSats), getColor(meSats + sats)] getColor(meSats), getColor(meSats + sats)]
}, [item?.meSats, item?.meAnonSats, pendingSats, me?.privates?.tipDefault, me?.privates?.turboDefault]) }, [item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault])
const optimisticUpdate = useCallback((sats, { onClose } = {}) => {
setPendingSats(pendingSats => pendingSats + sats)
strike()
onClose?.()
return () => {
setPendingSats(pendingSats => pendingSats - sats)
}
}, [])
const handleModalClosed = () => { const handleModalClosed = () => {
setHover(false) setHover(false)
@ -186,13 +163,11 @@ export default function UpVote ({ item, className }) {
setController(null) setController(null)
return return
} }
const c = new ZapUndoController() const c = new ZapUndoController({ onStart: () => setPending(true), onDone: () => setPending(false) })
setController(c) setController(c)
showModal(onClose => showModal(onClose =>
<ItemAct <ItemAct onClose={onClose} item={item} abortSignal={c.signal} />, { onClose: handleModalClosed })
onClose={onClose} item={item} abortSignal={c.signal} optimisticUpdate={optimisticUpdate}
/>, { onClose: handleModalClosed })
} }
const handleShortPress = async () => { const handleShortPress = async () => {
@ -215,12 +190,12 @@ export default function UpVote ({ item, className }) {
setController(null) setController(null)
return return
} }
const c = new ZapUndoController() const c = new ZapUndoController({ onStart: () => setPending(true), onDone: () => setPending(false) })
setController(c) setController(c)
await zap({ item, me, abortSignal: c.signal, optimisticUpdate }) await zap({ item, me, abortSignal: c.signal })
} else { } else {
showModal(onClose => <ItemAct onClose={onClose} item={item} optimisticUpdate={optimisticUpdate} />, { onClose: handleModalClosed }) showModal(onClose => <ItemAct onClose={onClose} item={item} />, { onClose: handleModalClosed })
} }
} }

View File

@ -0,0 +1,116 @@
import { useRouter } from 'next/router'
import { useToast } from './toast'
import { usePaidMutation, paidActionCacheMods } from './use-paid-mutation'
import useCrossposter from './use-crossposter'
import { useCallback } from 'react'
import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
import gql from 'graphql-tag'
// this is intented to be compatible with upsert item mutations
// so that it can be reused for all post types and comments and we don't have
// to maintain several copies of the same code
// it's a bit much for an abstraction ... but it makes it easy to modify item-payment UX
// and other side effects like crossposting and redirection
// ... or I just spent too much time in this code and this is overcooked
export default function useItemSubmit (mutation,
{ item, sub, onSuccessfulSubmit, navigateOnSubmit = true, extraValues = {}, paidMutationOptions = { } } = {}) {
const router = useRouter()
const toaster = useToast()
const crossposter = useCrossposter()
const [upsertItem] = usePaidMutation(mutation)
return useCallback(
async ({ boost, crosspost, title, options, bounty, maxBid, start, stop, ...values }, { resetForm }) => {
if (options) {
// remove existing poll options since else they will be appended as duplicates
options = options.slice(item?.poll?.options?.length || 0).filter(o => o.trim().length > 0)
}
const { data, error, payError } = await upsertItem({
variables: {
id: item?.id,
sub: item?.subName || sub?.name,
boost: boost ? Number(boost) : undefined,
bounty: bounty ? Number(bounty) : undefined,
maxBid: (maxBid || Number(maxBid) === 0) ? Number(maxBid) : undefined,
status: start ? 'ACTIVE' : stop ? 'STOPPED' : undefined,
title: title?.trim(),
options,
...values,
forward: normalizeForwards(values.forward),
...extraValues
},
// if not a comment, we want the qr to persist on navigation
persistOnNavigate: navigateOnSubmit,
...paidMutationOptions,
onPayError: (e, cache, { data }) => {
paidActionCacheMods.onPayError(e, cache, { data })
paidMutationOptions?.onPayError?.(e, cache, { data })
},
onPaid: (cache, { data }) => {
paidActionCacheMods.onPaid(cache, { data })
paidMutationOptions?.onPaid?.(cache, { data })
},
onCompleted: (data) => {
onSuccessfulSubmit?.(data, { resetForm })
paidMutationOptions?.onCompleted?.(data)
}
})
if (error) throw error
if (payError) throw new Error('payment required')
// we don't know the mutation name, so we have to extract the result
const response = Object.values(data)[0]
const postId = response?.result?.id
if (crosspost && postId) {
await crossposter(postId)
}
toastUpsertSuccessMessages(toaster, data, Object.keys(data)[0], values.text)
// if we're not a comment, we want to redirect after the mutation
if (navigateOnSubmit) {
if (item) {
await router.push(`/items/${item.id}`)
} else {
await router.push(sub ? `/~${sub.name}/recent` : '/recent')
}
}
}, [upsertItem, router, crossposter, item, sub, onSuccessfulSubmit,
navigateOnSubmit, extraValues, paidMutationOptions]
)
}
export function useRetryCreateItem ({ id }) {
const [retryPaidAction] = usePaidMutation(
RETRY_PAID_ACTION,
{
...paidActionCacheMods,
update: (cache, { data }) => {
const response = Object.values(data)[0]
if (!response?.invoice) return
cache.modify({
id: `Item:${id}`,
fields: {
// this is a bit of a hack just to update the reference to the new invoice
invoice: () => cache.writeFragment({
id: `Invoice:${response.invoice.id}`,
fragment: gql`
fragment _ on Invoice {
bolt11
}
`,
data: { bolt11: response.invoice.bolt11 }
})
}
})
paidActionCacheMods?.update?.(cache, { data })
}
}
)
return retryPaidAction
}

View File

@ -0,0 +1,165 @@
import { useApolloClient, useMutation } from '@apollo/client'
import { useCallback, useState } from 'react'
import { InvoiceCanceledError, InvoiceExpiredError, useInvoice, useQrPayment, useWebLnPayment } from './payment'
/*
this is just like useMutation with a few changes:
1. pays an invoice returned by the mutation
2. takes an onPaid and onPayError callback, and additional options for payment behavior
- namely forceWaitForPayment which will always wait for the invoice to be paid
- and persistOnNavigate which will keep the invoice in the cache after navigation
3. onCompleted behaves a little differently, but analogously to useMutation, ie clientside side effects
of completion can still rely on it
a. it's called before the invoice is paid for optimistic updates
b. it's called after the invoice is paid for pessimistic updates
4. we return a payError field in the result object if the invoice fails to pay
*/
export function usePaidMutation (mutation,
{ onCompleted, ...options } = {}) {
options.optimisticResponse = addOptimisticResponseExtras(options.optimisticResponse)
const [mutate, result] = useMutation(mutation, options)
const waitForWebLnPayment = useWebLnPayment()
const waitForQrPayment = useQrPayment()
const invoiceWaiter = useInvoice()
const client = useApolloClient()
// innerResult is used to store/control the result of the mutation when innerMutate runs
const [innerResult, setInnerResult] = useState(result)
const waitForPayment = useCallback(async (invoice, persistOnNavigate = false) => {
let webLnError
const start = Date.now()
try {
return await waitForWebLnPayment(invoice)
} catch (err) {
if (Date.now() - start > 1000 || err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) {
// bail since qr code payment will also fail
// also bail if the payment took more than 1 second
throw err
}
webLnError = err
}
return await waitForQrPayment(invoice, webLnError, { persistOnNavigate })
}, [waitForWebLnPayment, waitForQrPayment])
const innerMutate = useCallback(async ({
onCompleted: innerOnCompleted, ...innerOptions
} = {}) => {
innerOptions.optimisticResponse = addOptimisticResponseExtras(innerOptions.optimisticResponse)
let { data, ...rest } = await mutate(innerOptions)
// use the most inner callbacks/options if they exist
const { onPaid, onPayError, forceWaitForPayment, persistOnNavigate } = { ...options, ...innerOptions }
const ourOnCompleted = innerOnCompleted || onCompleted
// get invoice without knowing the mutation name
if (Object.values(data).length !== 1) {
throw new Error('usePaidMutation: exactly one mutation at a time is supported')
}
const response = Object.values(data)[0]
const invoice = response?.invoice
// if the mutation returns an invoice, pay it
if (invoice) {
// should we wait for the invoice to be paid?
if (response?.paymentMethod === 'OPTIMISTIC' && !forceWaitForPayment) {
// onCompleted is called before the invoice is paid for optimistic updates
ourOnCompleted?.(data)
// don't wait to pay the invoice
waitForPayment(invoice, persistOnNavigate).then(() => {
onPaid?.(client.cache, { data })
}).catch(e => {
console.error('usePaidMutation: failed to pay invoice', e)
// onPayError is called after the invoice fails to pay
// useful for updating invoiceActionState to FAILED
onPayError?.(e, client.cache, { data })
setInnerResult(r => ({ payError: e, ...r }))
})
} else {
try {
// wait for the invoice to be held
await waitForPayment(invoice, persistOnNavigate);
// and the mutation to complete
({ data, ...rest } = await mutate({
...innerOptions,
variables: {
...options.variables,
...innerOptions.variables,
hmac: invoice.hmac,
hash: invoice.hash
}
}))
// block until the invoice to be marked as paid
// for pessimisitic actions, they won't show up on navigation until they are marked as paid
await invoiceWaiter.waitUntilPaid(invoice, inv => inv?.actionState === 'PAID')
ourOnCompleted?.(data)
onPaid?.(client.cache, { data })
} catch (e) {
console.error('usePaidMutation: failed to pay invoice', e)
onPayError?.(e, client.cache, { data })
rest = { payError: e, ...rest }
} finally {
invoiceWaiter.stopWaiting()
}
}
} else {
// fee credits paid for it
ourOnCompleted?.(data)
onPaid?.(client.cache, { data })
}
setInnerResult({ data, ...rest })
return { data, ...rest }
}, [mutate, options, waitForPayment, onCompleted, client.cache])
return [innerMutate, innerResult]
}
// all paid actions need these fields and they're easy to forget
function addOptimisticResponseExtras (optimisticResponse) {
if (!optimisticResponse) return optimisticResponse
const key = Object.keys(optimisticResponse)[0]
optimisticResponse[key] = { invoice: null, paymentMethod: 'OPTIMISTIC', ...optimisticResponse[key] }
return optimisticResponse
}
// most paid actions have the same cache modifications
// these let us preemptively update the cache before a query updates it
export const paidActionCacheMods = {
update: (cache, { data }) => {
const response = Object.values(data)[0]
if (!response?.invoice) return
const { invoice } = response
cache.modify({
id: `Invoice:${invoice.id}`,
fields: {
actionState: () => 'PENDING'
}
})
},
onPayError: (e, cache, { data }) => {
const response = Object.values(data)[0]
if (!response?.invoice) return
const { invoice } = response
cache.modify({
id: `Invoice:${invoice.id}`,
fields: {
actionState: () => 'FAILED'
}
})
},
onPaid: (cache, { data }) => {
const response = Object.values(data)[0]
if (!response?.invoice) return
const { invoice } = response
cache.modify({
id: `Invoice:${invoice.id}`,
fields: {
actionState: () => 'PAID',
confirmedAt: () => new Date().toISOString()
}
})
}
}

View File

@ -148,9 +148,7 @@ function NymEdit ({ user, setEditting }) {
return return
} }
const { error } = await setName({ variables: { name } }) const { error } = await setName({ variables: { name } })
if (error) { if (error) throw error
throw new Error({ message: error.toString() })
}
setEditting(false) setEditting(false)
// navigate to new name // navigate to new name

View File

@ -29,26 +29,27 @@ export function NWCProvider ({ children }) {
const getInfo = useCallback(async (relayUrl, walletPubkey) => { const getInfo = useCallback(async (relayUrl, walletPubkey) => {
logger.info(`requesting info event from ${relayUrl}`) logger.info(`requesting info event from ${relayUrl}`)
let relay, sub let relay
try { try {
relay = await Relay.connect(relayUrl).catch(() => { relay = await Relay.connect(relayUrl)
// NOTE: passed error is undefined for some reason
const msg = `failed to connect to ${relayUrl}`
logger.error(msg)
throw new Error(msg)
})
logger.ok(`connected to ${relayUrl}`) logger.ok(`connected to ${relayUrl}`)
} catch (err) {
const msg = `failed to connect to ${relayUrl}`
logger.error(msg)
throw new Error(msg)
}
try {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const timeout = 5000 const timeout = 5000
const timer = setTimeout(() => { const timer = setTimeout(() => {
const msg = 'timeout waiting for info event' const msg = 'timeout waiting for info event'
logger.error(msg) logger.error(msg)
reject(new Error(msg)) reject(new Error(msg))
sub?.close()
}, timeout) }, timeout)
let found = false let found = false
sub = relay.subscribe([ relay.subscribe([
{ {
kinds: [13194], kinds: [13194],
authors: [walletPubkey] authors: [walletPubkey]
@ -76,13 +77,11 @@ export function NWCProvider ({ children }) {
logger.error(msg) logger.error(msg)
reject(new Error(msg)) reject(new Error(msg))
} }
sub?.close()
} }
}) })
}) })
} finally { } finally {
// For some reason, websocket is already in CLOSING or CLOSED state. relay?.close()?.catch()
// relay?.close()
if (relay) logger.info(`closed connection to ${relayUrl}`) if (relay) logger.info(`closed connection to ${relayUrl}`)
} }
}, [logger]) }, [logger])
@ -193,15 +192,17 @@ export function NWCProvider ({ children }) {
const hash = bolt11Tags(bolt11).payment_hash const hash = bolt11Tags(bolt11).payment_hash
logger.info('sending payment:', `payment_hash=${hash}`) logger.info('sending payment:', `payment_hash=${hash}`)
let relay, sub let relay
try { try {
relay = await Relay.connect(relayUrl).catch(() => { relay = await Relay.connect(relayUrl)
// NOTE: passed error is undefined for some reason
const msg = `failed to connect to ${relayUrl}`
logger.error(msg)
throw new Error(msg)
})
logger.ok(`connected to ${relayUrl}`) logger.ok(`connected to ${relayUrl}`)
} catch (err) {
const msg = `failed to connect to ${relayUrl}`
logger.error(msg)
throw new Error(msg)
}
try {
const ret = await new Promise(function (resolve, reject) { const ret = await new Promise(function (resolve, reject) {
(async function () { (async function () {
// timeout since NWC is async (user needs to confirm payment in wallet) // timeout since NWC is async (user needs to confirm payment in wallet)
@ -211,7 +212,6 @@ export function NWCProvider ({ children }) {
const msg = 'timeout waiting for payment' const msg = 'timeout waiting for payment'
logger.error(msg) logger.error(msg)
reject(new InvoiceExpiredError(hash)) reject(new InvoiceExpiredError(hash))
sub?.close()
}, timeout) }, timeout)
const payload = { const payload = {
@ -233,7 +233,7 @@ export function NWCProvider ({ children }) {
authors: [walletPubkey], authors: [walletPubkey],
'#e': [request.id] '#e': [request.id]
} }
sub = relay.subscribe([filter], { relay.subscribe([filter], {
async onevent (response) { async onevent (response) {
clearTimeout(timer) clearTimeout(timer)
try { try {
@ -263,8 +263,7 @@ export function NWCProvider ({ children }) {
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.()) logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
throw err throw err
} finally { } finally {
// For some reason, websocket is already in CLOSING or CLOSED state. relay?.close()?.catch()
// relay?.close()
if (relay) logger.info(`closed connection to ${relayUrl}`) if (relay) logger.info(`closed connection to ${relayUrl}`)
} }
}, [walletPubkey, relayUrl, secret, logger]) }, [walletPubkey, relayUrl, secret, logger])

View File

@ -35,6 +35,11 @@ export const COMMENT_FIELDS = gql`
imgproxyUrls imgproxyUrls
rel rel
apiKey apiKey
invoice {
id
actionState
confirmedAt
}
} }
` `

View File

@ -60,6 +60,11 @@ export const ITEM_FIELDS = gql`
imgproxyUrls imgproxyUrls
rel rel
apiKey apiKey
invoice {
id
actionState
confirmedAt
}
}` }`
export const ITEM_FULL_FIELDS = gql` export const ITEM_FULL_FIELDS = gql`
@ -121,6 +126,8 @@ export const POLL_FIELDS = gql`
fragment PollFields on Item { fragment PollFields on Item {
poll { poll {
meVoted meVoted
meInvoiceId
meInvoiceActionState
count count
options { options {
id id

View File

@ -1,12 +1,37 @@
import { gql } from '@apollo/client' import { gql } from '@apollo/client'
import { ITEM_FULL_FIELDS } from './items' import { ITEM_FULL_FIELDS, POLL_FIELDS } from './items'
import { INVITE_FIELDS } from './invites' import { INVITE_FIELDS } from './invites'
import { SUB_FIELDS } from './subs' import { SUB_FIELDS } from './subs'
import { INVOICE_FIELDS } from './wallet'
export const HAS_NOTIFICATIONS = gql`{ hasNewNotes }` export const HAS_NOTIFICATIONS = gql`{ hasNewNotes }`
export const NOTIFICATIONS = gql` export const INVOICIFICATION = gql`
${ITEM_FULL_FIELDS} ${ITEM_FULL_FIELDS}
${POLL_FIELDS}
${INVOICE_FIELDS}
fragment InvoicificationFields on Invoicification {
id
sortTime
invoice {
...InvoiceFields
item {
...ItemFullFields
...PollFields
}
itemAct {
id
act
invoice {
id
actionState
}
}
}
}`
export const NOTIFICATIONS = gql`
${INVOICIFICATION}
${INVITE_FIELDS} ${INVITE_FIELDS}
${SUB_FIELDS} ${SUB_FIELDS}
@ -28,7 +53,7 @@ export const NOTIFICATIONS = gql`
... on ItemMention { ... on ItemMention {
id id
sortTime sortTime
item { item {
...ItemFullFields ...ItemFullFields
text text
} }
@ -141,6 +166,9 @@ export const NOTIFICATIONS = gql`
lud18Data lud18Data
} }
} }
... on Invoicification {
...InvoicificationFields
}
... on WithdrawlPaid { ... on WithdrawlPaid {
id id
sortTime sortTime

232
fragments/paidAction.js Normal file
View File

@ -0,0 +1,232 @@
import gql from 'graphql-tag'
import { COMMENTS } from './comments'
import { SUB_FULL_FIELDS } from './subs'
import { INVOICE_FIELDS } from './wallet'
export const PAID_ACTION = gql`
${INVOICE_FIELDS}
fragment PaidActionFields on PaidAction {
invoice {
...InvoiceFields
}
paymentMethod
}`
const ITEM_PAID_ACTION_FIELDS = gql`
${COMMENTS}
fragment ItemPaidActionFields on ItemPaidAction {
result {
id
deleteScheduledAt
reminderScheduledAt
...CommentFields
comments {
...CommentsRecursive
}
}
}`
const ITEM_ACT_PAID_ACTION_FIELDS = gql`
fragment ItemActPaidActionFields on ItemActPaidAction {
result {
id
sats
path
act
}
}`
export const RETRY_PAID_ACTION = gql`
${PAID_ACTION}
${ITEM_PAID_ACTION_FIELDS}
${ITEM_ACT_PAID_ACTION_FIELDS}
mutation retryPaidAction($invoiceId: Int!) {
retryPaidAction(invoiceId: $invoiceId) {
__typename
...PaidActionFields
... on ItemPaidAction {
...ItemPaidActionFields
}
... on ItemActPaidAction {
...ItemActPaidActionFields
}
... on PollVotePaidAction {
result {
id
}
}
}
}`
export const DONATE = gql`
${PAID_ACTION}
mutation donateToRewards($sats: Int!, $hash: String, $hmac: String) {
donateToRewards(sats: $sats, hash: $hash, hmac: $hmac) {
result {
sats
}
...PaidActionFields
}
}`
export const ACT_MUTATION = gql`
${PAID_ACTION}
${ITEM_ACT_PAID_ACTION_FIELDS}
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
...ItemActPaidActionFields
...PaidActionFields
}
}`
export const UPSERT_DISCUSSION = gql`
${PAID_ACTION}
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String,
$boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost,
forward: $forward, hash: $hash, hmac: $hmac) {
result {
id
deleteScheduledAt
reminderScheduledAt
}
...PaidActionFields
}
}`
export const UPSERT_JOB = gql`
${PAID_ACTION}
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!,
$location: String, $remote: Boolean, $text: String!, $url: String!, $maxBid: Int!,
$status: String, $logo: Int, $hash: String, $hmac: String) {
upsertJob(sub: $sub, id: $id, title: $title, company: $company,
location: $location, remote: $remote, text: $text,
url: $url, maxBid: $maxBid, status: $status, logo: $logo, hash: $hash, hmac: $hmac) {
result {
id
deleteScheduledAt
reminderScheduledAt
}
...PaidActionFields
}
}`
export const UPSERT_LINK = gql`
${PAID_ACTION}
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!,
$text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
upsertLink(sub: $sub, id: $id, title: $title, url: $url, text: $text,
boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
result {
id
deleteScheduledAt
reminderScheduledAt
}
...PaidActionFields
}
}`
export const UPSERT_POLL = gql`
${PAID_ACTION}
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
$options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $hash: String,
$hmac: String, $pollExpiresAt: Date) {
upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
options: $options, boost: $boost, forward: $forward, hash: $hash,
hmac: $hmac, pollExpiresAt: $pollExpiresAt) {
result {
id
deleteScheduledAt
reminderScheduledAt
}
...PaidActionFields
}
}`
export const UPSERT_BOUNTY = gql`
${PAID_ACTION}
mutation upsertBounty($sub: String, $id: ID, $title: String!, $bounty: Int!,
$text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
upsertBounty(sub: $sub, id: $id, title: $title, bounty: $bounty, text: $text,
boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
result {
id
deleteScheduledAt
reminderScheduledAt
}
...PaidActionFields
}
}`
export const POLL_VOTE = gql`
${PAID_ACTION}
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
pollVote(id: $id, hash: $hash, hmac: $hmac) {
result {
id
}
...PaidActionFields
}
}`
export const CREATE_COMMENT = gql`
${ITEM_PAID_ACTION_FIELDS}
${PAID_ACTION}
mutation upsertComment($text: String!, $parentId: ID!, $hash: String, $hmac: String) {
upsertComment(text: $text, parentId: $parentId, hash: $hash, hmac: $hmac) {
...ItemPaidActionFields
...PaidActionFields
}
}`
export const UPDATE_COMMENT = gql`
${ITEM_PAID_ACTION_FIELDS}
${PAID_ACTION}
mutation upsertComment($id: ID!, $text: String!, $hash: String, $hmac: String) {
upsertComment(id: $id, text: $text, hash: $hash, hmac: $hmac) {
...ItemPaidActionFields
...PaidActionFields
}
}`
export const UPSERT_SUB = gql`
${PAID_ACTION}
mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String, $nsfw: Boolean!) {
upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac, nsfw: $nsfw) {
result {
name
}
...PaidActionFields
}
}`
export const UNARCHIVE_TERRITORY = gql`
${PAID_ACTION}
mutation unarchiveTerritory($name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String, $nsfw: Boolean!) {
unarchiveTerritory(name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac, nsfw: $nsfw) {
result {
name
}
...PaidActionFields
}
}`
export const SUB_PAY = gql`
${SUB_FULL_FIELDS}
${PAID_ACTION}
mutation paySub($name: String!, $hash: String, $hmac: String) {
paySub(name: $name, hash: $hash, hmac: $hmac) {
result {
...SubFullFields
}
...PaidActionFields
}
}`

View File

@ -110,14 +110,6 @@ export const SUB_SEARCH = gql`
} }
` `
export const SUB_PAY = gql`
${SUB_FULL_FIELDS}
mutation paySub($name: String!, $hash: String, $hmac: String) {
paySub(name: $name, hash: $hash, hmac: $hmac) {
...SubFullFields
}
}`
export const TOP_SUBS = gql` export const TOP_SUBS = gql`
${SUB_FULL_FIELDS} ${SUB_FULL_FIELDS}
query TopSubs($cursor: String, $when: String, $from: String, $to: String, $by: String, ) { query TopSubs($cursor: String, $when: String, $from: String, $to: String, $by: String, ) {

View File

@ -1,22 +1,45 @@
import { gql } from '@apollo/client' import { gql } from '@apollo/client'
import { ITEM_FULL_FIELDS } from './items' import { ITEM_FULL_FIELDS } from './items'
export const INVOICE = gql` export const INVOICE_FIELDS = gql`
fragment InvoiceFields on Invoice {
id
hash
hmac
bolt11
satsRequested
satsReceived
cancelled
confirmedAt
expiresAt
nostr
isHeld
comment
lud18Data
confirmedPreimage
actionState
actionType
}`
export const INVOICE_FULL = gql`
${ITEM_FULL_FIELDS}
${INVOICE_FIELDS}
query Invoice($id: ID!) { query Invoice($id: ID!) {
invoice(id: $id) { invoice(id: $id) {
id ...InvoiceFields
hash item {
bolt11 ...ItemFullFields
satsRequested }
satsReceived }
cancelled }`
confirmedAt
expiresAt export const INVOICE = gql`
nostr ${INVOICE_FIELDS}
isHeld
comment query Invoice($id: ID!) {
lud18Data invoice(id: $id) {
confirmedPreimage ...InvoiceFields
} }
}` }`

View File

@ -32,6 +32,37 @@ function getClient (uri) {
connectToDevTools: process.env.NODE_ENV !== 'production', connectToDevTools: process.env.NODE_ENV !== 'production',
cache: new InMemoryCache({ cache: new InMemoryCache({
freezeResults: true, freezeResults: true,
// https://github.com/apollographql/apollo-client/issues/7648
possibleTypes: {
PaidAction: [
'ItemPaidAction',
'ItemActPaidAction',
'PollVotePaidAction',
'SubPaidAction',
'DonatePaidAction'
],
Notification: [
'Reply',
'Votification',
'Mention',
'Invitification',
'Earn',
'JobChange',
'InvoicePaid',
'WithdrawlPaid',
'Referral',
'Streak',
'FollowActivity',
'ForwardedVotification',
'Revenue',
'SubStatus',
'TerritoryPost',
'TerritoryTransfer',
'Reminder',
'ItemMention',
'Invoicification'
]
},
typePolicies: { typePolicies: {
Sub: { Sub: {
keyFields: ['name'], keyFields: ['name'],
@ -49,6 +80,9 @@ function getClient (uri) {
}, },
optional: { optional: {
merge: true merge: true
},
bio: {
merge: true
} }
} }
}, },

View File

@ -12,7 +12,7 @@ export const UPLOAD_SIZE_MAX_AVATAR = 5 * 1024 * 1024
export const IMAGE_PIXELS_MAX = 35000000 export const IMAGE_PIXELS_MAX = 35000000
// backwards compatibile with old media domain env var and precedence for docker url if set // backwards compatibile with old media domain env var and precedence for docker url if set
export const MEDIA_URL = process.env.MEDIA_URL_DOCKER || process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}` export const MEDIA_URL = process.env.MEDIA_URL_DOCKER || process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}`
export const AWS_S3_URL_REGEXP = new RegExp(`${MEDIA_URL}/([0-9]+)`, 'g') export const AWS_S3_URL_REGEXP = new RegExp(`${process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}`}/([0-9]+)`, 'g')
export const UPLOAD_TYPES_ALLOW = [ export const UPLOAD_TYPES_ALLOW = [
'image/gif', 'image/gif',
'image/heic', 'image/heic',
@ -20,6 +20,7 @@ export const UPLOAD_TYPES_ALLOW = [
'image/jpeg', 'image/jpeg',
'image/webp' 'image/webp'
] ]
export const INVOICE_ACTION_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'POLL_VOTE']
export const BOUNTY_MIN = 1000 export const BOUNTY_MIN = 1000
export const BOUNTY_MAX = 10000000 export const BOUNTY_MAX = 10000000
export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL'] export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL']

View File

@ -13,71 +13,30 @@ export const normalizeForwards = (forward) => {
return forward.filter(fwd => fwd.nym || fwd.user?.name).map(fwd => ({ nym: fwd.nym ?? fwd.user?.name, pct: Number(fwd.pct) })) return forward.filter(fwd => fwd.nym || fwd.user?.name).map(fwd => ({ nym: fwd.nym ?? fwd.user?.name, pct: Number(fwd.pct) }))
} }
export const toastUpsertSuccessMessages = (toaster, upsertResponseData, dataKey, isEdit, itemText) => { export const toastUpsertSuccessMessages = (toaster, upsertResponseData, dataKey, itemText) => {
toastDeleteScheduled(toaster, upsertResponseData, dataKey, isEdit, itemText) const SCHEDULERS = {
toastReminderScheduled(toaster, upsertResponseData, dataKey, isEdit, itemText) delete: {
} hasMention: hasDeleteMention,
scheduledAtKey: 'deleteScheduledAt',
const toastDeleteScheduled = (toaster, upsertResponseData, dataKey, isEdit, itemText) => { mention: '@delete'
const data = upsertResponseData[dataKey] },
if (!data) return remindme: {
hasMention: hasReminderMention,
const deleteMentioned = hasDeleteMention(itemText) scheduledAtKey: 'reminderScheduledAt',
const deletedScheduled = !!data.deleteScheduledAt mention: '@remindme'
}
if (!deleteMentioned) return
if (deleteMentioned && !deletedScheduled) {
// There's a delete mention but the deletion wasn't scheduled
toaster.warning('it looks like you tried to use the delete bot but it didn\'t work. make sure you use the correct format: "@delete in n units" e.g. "@delete in 2 hours"', 10000)
return
} }
// when we reached this code, we know that a delete was scheduled for (const key in SCHEDULERS) {
const deleteScheduledAt = new Date(data.deleteScheduledAt) const { hasMention, scheduledAtKey, mention } = SCHEDULERS[key]
if (deleteScheduledAt) { if (hasMention(itemText)) {
const itemType = { const scheduledAt = upsertResponseData[dataKey]?.result?.[scheduledAtKey]
upsertDiscussion: 'discussion post', const options = { persistOnNavigate: dataKey !== 'upsertComment' }
upsertLink: 'link post', if (scheduledAt) {
upsertPoll: 'poll', toaster.success(`${mention} bot will trigger at ${new Date(scheduledAt).toLocaleString()}`, options)
upsertBounty: 'bounty', } else {
upsertJob: 'job', toaster.warning(`It looks like you tried to use the ${mention} bot but it didn't work. Make sure you use the correct format: "${mention} in n units" e.g. "${mention} in 2 hours"`, options)
upsertComment: 'comment' }
}[dataKey] ?? 'item' }
const message = `${itemType === 'comment' ? 'your comment' : isEdit ? `this ${itemType}` : `your new ${itemType}`} will be deleted at ${deleteScheduledAt.toLocaleString()}`
// only persist this on navigation for posts, not comments
toaster.success(message, { persistOnNavigate: itemType !== 'comment' })
}
}
const toastReminderScheduled = (toaster, upsertResponseData, dataKey, isEdit, itemText) => {
const data = upsertResponseData[dataKey]
if (!data) return
const reminderMentioned = hasReminderMention(itemText)
const reminderScheduled = !!data.reminderScheduledAt
if (!reminderMentioned) return
if (reminderMentioned && !reminderScheduled) {
// There's a reminder mention but the reminder wasn't scheduled
toaster.warning('it looks like you tried to use the remindme bot but it didn\'t work. make sure you use the correct format: "@remindme in n units" e.g. "@remindme in 2 hours"', 10000)
return
}
// when we reached this code, we know that a reminder was scheduled
const reminderScheduledAt = new Date(data.reminderScheduledAt)
if (reminderScheduledAt) {
const itemType = {
upsertDiscussion: 'discussion post',
upsertLink: 'link post',
upsertPoll: 'poll',
upsertBounty: 'bounty',
upsertJob: 'job',
upsertComment: 'comment'
}[dataKey] ?? 'item'
const message = `you will be reminded of ${itemType === 'comment' ? 'your comment' : isEdit ? `this ${itemType}` : `your new ${itemType}`} at ${reminderScheduledAt.toLocaleString()}`
// only persist this on navigation for posts, not comments
toaster.success(message, { persistOnNavigate: itemType !== 'comment' })
} }
} }

View File

@ -26,10 +26,28 @@ export const hasDeleteMention = (text) => deleteMentionPattern.test(text ?? '')
export const getDeleteCommand = (text) => { export const getDeleteCommand = (text) => {
if (!text) return false if (!text) return false
const matches = [...text.matchAll(deletePattern)] const matches = [...text.matchAll(deletePattern)]
const commands = matches?.map(match => ({ number: match[1], unit: match[2] })) const commands = matches?.map(match => ({ number: parseInt(match[1]), unit: match[2] }))
return commands.length ? commands[commands.length - 1] : undefined return commands.length ? commands[commands.length - 1] : undefined
} }
export const getDeleteAt = (text) => {
const command = getDeleteCommand(text)
if (command) {
const { number, unit } = command
return datePivot(new Date(), { [`${unit}s`]: number })
}
return null
}
export const getRemindAt = (text) => {
const command = getReminderCommand(text)
if (command) {
const { number, unit } = command
return datePivot(new Date(), { [`${unit}s`]: number })
}
return null
}
export const hasDeleteCommand = (text) => !!getDeleteCommand(text) export const hasDeleteCommand = (text) => !!getDeleteCommand(text)
export const hasReminderMention = (text) => reminderMentionPattern.test(text ?? '') export const hasReminderMention = (text) => reminderMentionPattern.test(text ?? '')
@ -37,7 +55,7 @@ export const hasReminderMention = (text) => reminderMentionPattern.test(text ??
export const getReminderCommand = (text) => { export const getReminderCommand = (text) => {
if (!text) return false if (!text) return false
const matches = [...text.matchAll(reminderPattern)] const matches = [...text.matchAll(reminderPattern)]
const commands = matches?.map(match => ({ number: match[1], unit: match[2] })) const commands = matches?.map(match => ({ number: parseInt(match[1]), unit: match[2] }))
return commands.length ? commands[commands.length - 1] : undefined return commands.length ? commands[commands.length - 1] : undefined
} }
@ -65,9 +83,28 @@ export const deleteItemByAuthor = async ({ models, id, item }) => {
updateData.pollCost = null updateData.pollCost = null
} }
await deleteReminders({ id, userId: item.userId, models })
return await models.item.update({ where: { id: Number(id) }, data: updateData }) return await models.item.update({ where: { id: Number(id) }, data: updateData })
} }
export const deleteReminders = async ({ id, userId, models }) => {
await models.$queryRaw`
DELETE FROM pgboss.job
WHERE name = 'reminder'
AND data->>'itemId' = ${id}::TEXT
AND data->>'userId' = ${userId}::TEXT
AND state <> 'completed'`
await models.reminder.deleteMany({
where: {
itemId: Number(id),
userId: Number(userId),
remindAt: {
gt: new Date()
}
}
})
}
export const commentSubTreeRootId = (item) => { export const commentSubTreeRootId = (item) => {
const path = item.path.split('.') const path = item.path.split('.')
return path.slice(-(COMMENT_DEPTH_LIMIT - 1))[0] return path.slice(-(COMMENT_DEPTH_LIMIT - 1))[0]

73
package-lock.json generated
View File

@ -16,7 +16,7 @@
"@lightninglabs/lnc-web": "^0.3.1-alpha", "@lightninglabs/lnc-web": "^0.3.1-alpha",
"@noble/curves": "^1.2.0", "@noble/curves": "^1.2.0",
"@opensearch-project/opensearch": "^2.4.0", "@opensearch-project/opensearch": "^2.4.0",
"@prisma/client": "^5.4.2", "@prisma/client": "^5.14.0",
"@slack/web-api": "^6.9.0", "@slack/web-api": "^6.9.0",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@yudiel/react-qr-scanner": "^1.1.10", "@yudiel/react-qr-scanner": "^1.1.10",
@ -62,7 +62,7 @@
"page-metadata-parser": "^1.1.4", "page-metadata-parser": "^1.1.4",
"pg-boss": "^9.0.3", "pg-boss": "^9.0.3",
"piexifjs": "^1.0.6", "piexifjs": "^1.0.6",
"prisma": "^5.4.2", "prisma": "^5.14.0",
"qrcode.react": "^3.1.0", "qrcode.react": "^3.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-avatar-editor": "^13.0.0", "react-avatar-editor": "^13.0.0",
@ -4092,13 +4092,10 @@
} }
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "5.4.2", "version": "5.14.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.14.0.tgz",
"integrity": "sha512-2xsPaz4EaMKj1WS9iW6MlPhmbqtBsXAOeVttSePp8vTFTtvzh2hZbDgswwBdSCgPzmmwF+tLB259QzggvCmJqA==", "integrity": "sha512-akMSuyvLKeoU4LeyBAUdThP/uhVP3GuLygFE3MlYzaCb3/J8SfsYBE5PkaFuLuVpLyA6sFoW+16z/aPhNAESqg==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": {
"@prisma/engines-version": "5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574"
},
"engines": { "engines": {
"node": ">=16.13" "node": ">=16.13"
}, },
@ -4111,16 +4108,50 @@
} }
} }
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/debug": {
"version": "5.4.2", "version": "5.14.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.4.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.14.0.tgz",
"integrity": "sha512-fqeucJ3LH0e1eyFdT0zRx+oETLancu5+n4lhiYECyEz6H2RDskPJHJYHkVc0LhkU4Uv7fuEnppKU3nVKNzMh8g==", "integrity": "sha512-iq56qBZuFfX3fCxoxT8gBX33lQzomBU0qIUaEj1RebsKVz1ob/BVH1XSBwwwvRVtZEV1b7Fxx2eVu34Ge/mg3w=="
"hasInstallScript": true
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines": {
"version": "5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574", "version": "5.14.0",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.14.0.tgz",
"integrity": "sha512-wvupDL4AA1vf4TQNANg7kR7y98ITqPsk6aacfBxZKtrJKRIsWjURHkZCGcQliHdqCiW/hGreO6d6ZuSv9MhdAA==" "integrity": "sha512-lgxkKZ6IEygVcw6IZZUlPIfLQ9hjSYAtHjZ5r64sCLDgVzsPFCi2XBBJgzPMkOQ5RHzUD4E/dVdpn9+ez8tk1A==",
"hasInstallScript": true,
"dependencies": {
"@prisma/debug": "5.14.0",
"@prisma/engines-version": "5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48",
"@prisma/fetch-engine": "5.14.0",
"@prisma/get-platform": "5.14.0"
}
},
"node_modules/@prisma/engines/node_modules/@prisma/engines-version": {
"version": "5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48.tgz",
"integrity": "sha512-ip6pNkRo1UxWv+6toxNcYvItNYaqQjXdFNGJ+Nuk2eYtRoEdoF13wxo7/jsClJFFenMPVNVqXQDV0oveXnR1cA=="
},
"node_modules/@prisma/fetch-engine": {
"version": "5.14.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.14.0.tgz",
"integrity": "sha512-VrheA9y9DMURK5vu8OJoOgQpxOhas3qF0IBHJ8G/0X44k82kc8E0w98HCn2nhnbOOMwbWsJWXfLC2/F8n5u0gQ==",
"dependencies": {
"@prisma/debug": "5.14.0",
"@prisma/engines-version": "5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48",
"@prisma/get-platform": "5.14.0"
}
},
"node_modules/@prisma/fetch-engine/node_modules/@prisma/engines-version": {
"version": "5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48.tgz",
"integrity": "sha512-ip6pNkRo1UxWv+6toxNcYvItNYaqQjXdFNGJ+Nuk2eYtRoEdoF13wxo7/jsClJFFenMPVNVqXQDV0oveXnR1cA=="
},
"node_modules/@prisma/get-platform": {
"version": "5.14.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.14.0.tgz",
"integrity": "sha512-/yAyBvcEjRv41ynZrhdrPtHgk47xLRRq/o5eWGcUpBJ1YrUZTYB8EoPiopnP7iQrMATK8stXQdPOoVlrzuTQZw==",
"dependencies": {
"@prisma/debug": "5.14.0"
}
}, },
"node_modules/@protobufjs/aspromise": { "node_modules/@protobufjs/aspromise": {
"version": "1.1.2", "version": "1.1.2",
@ -15787,12 +15818,12 @@
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "5.4.2", "version": "5.14.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.4.2.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.14.0.tgz",
"integrity": "sha512-GDMZwZy7mysB2oXU+angQqJ90iaPFdD0rHaZNkn+dio5NRkGLmMqmXs31//tg/qXT3iB0cTQwnGGQNuirhSTZg==", "integrity": "sha512-gCNZco7y5XtjrnQYeDJTiVZmT/ncqCr5RY1/Cf8X2wgLRmyh9ayPAGBNziI4qEE4S6SxCH5omQLVo9lmURaJ/Q==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/engines": "5.4.2" "@prisma/engines": "5.14.0"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"

View File

@ -21,7 +21,7 @@
"@lightninglabs/lnc-web": "^0.3.1-alpha", "@lightninglabs/lnc-web": "^0.3.1-alpha",
"@noble/curves": "^1.2.0", "@noble/curves": "^1.2.0",
"@opensearch-project/opensearch": "^2.4.0", "@opensearch-project/opensearch": "^2.4.0",
"@prisma/client": "^5.4.2", "@prisma/client": "^5.14.0",
"@slack/web-api": "^6.9.0", "@slack/web-api": "^6.9.0",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@yudiel/react-qr-scanner": "^1.1.10", "@yudiel/react-qr-scanner": "^1.1.10",
@ -67,7 +67,7 @@
"page-metadata-parser": "^1.1.4", "page-metadata-parser": "^1.1.4",
"pg-boss": "^9.0.3", "pg-boss": "^9.0.3",
"piexifjs": "^1.0.6", "piexifjs": "^1.0.6",
"prisma": "^5.4.2", "prisma": "^5.14.0",
"qrcode.react": "^3.1.0", "qrcode.react": "^3.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-avatar-editor": "^13.0.0", "react-avatar-editor": "^13.0.0",

View File

@ -57,9 +57,7 @@ export function BioForm ({ handleDone, bio }) {
schema={bioSchema} schema={bioSchema}
onSubmit={async values => { onSubmit={async values => {
const { error } = await upsertBio({ variables: values }) const { error } = await upsertBio({ variables: values })
if (error) { if (error) throw error
throw new Error({ message: error.toString() })
}
handleDone?.() handleDone?.()
}} }}
> >

View File

@ -21,7 +21,6 @@ import { ChainFeeProvider } from '@/components/chain-fee.js'
import { WebLNProvider } from '@/components/webln' import { WebLNProvider } from '@/components/webln'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { HasNewNotesProvider } from '@/components/use-has-new-notes' import { HasNewNotesProvider } from '@/components/use-has-new-notes'
import { ClientNotificationProvider } from '@/components/client-notifications'
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
@ -105,30 +104,28 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<ApolloProvider client={client}> <ApolloProvider client={client}>
<MeProvider me={me}> <MeProvider me={me}>
<HasNewNotesProvider> <HasNewNotesProvider>
<ClientNotificationProvider> <LoggerProvider>
<LoggerProvider> <ServiceWorkerProvider>
<ServiceWorkerProvider> <PriceProvider price={price}>
<PriceProvider price={price}> <LightningProvider>
<LightningProvider> <ToastProvider>
<ToastProvider> <WebLNProvider>
<WebLNProvider> <ShowModalProvider>
<ShowModalProvider> <BlockHeightProvider blockHeight={blockHeight}>
<BlockHeightProvider blockHeight={blockHeight}> <ChainFeeProvider chainFee={chainFee}>
<ChainFeeProvider chainFee={chainFee}> <ErrorBoundary>
<ErrorBoundary> <Component ssrData={ssrData} {...otherProps} />
<Component ssrData={ssrData} {...otherProps} /> {!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />} </ErrorBoundary>
</ErrorBoundary> </ChainFeeProvider>
</ChainFeeProvider> </BlockHeightProvider>
</BlockHeightProvider> </ShowModalProvider>
</ShowModalProvider> </WebLNProvider>
</WebLNProvider> </ToastProvider>
</ToastProvider> </LightningProvider>
</LightningProvider> </PriceProvider>
</PriceProvider> </ServiceWorkerProvider>
</ServiceWorkerProvider> </LoggerProvider>
</LoggerProvider>
</ClientNotificationProvider>
</HasNewNotesProvider> </HasNewNotesProvider>
</MeProvider> </MeProvider>
</ApolloProvider> </ApolloProvider>

View File

@ -51,9 +51,7 @@ function InviteForm () {
gift: Number(gift), limit: limit ? Number(limit) : limit gift: Number(gift), limit: limit ? Number(limit) : limit
} }
}) })
if (error) { if (error) throw error
throw new Error({ message: error.String() })
}
}} }}
> >
<Input <Input

View File

@ -1,30 +1,18 @@
import { useQuery } from '@apollo/client'
import Invoice from '@/components/invoice' import Invoice from '@/components/invoice'
import { QrSkeleton } from '@/components/qr'
import { CenterLayout } from '@/components/layout' import { CenterLayout } from '@/components/layout'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { INVOICE } from '@/fragments/wallet' import { INVOICE_FULL } from '@/fragments/wallet'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { getGetServerSideProps } from '@/api/ssrApollo' import { getGetServerSideProps } from '@/api/ssrApollo'
// force SSR to include CSP nonces // force SSR to include CSP nonces
export const getServerSideProps = getGetServerSideProps({ query: null }) export const getServerSideProps = getGetServerSideProps({ query: null })
// TODO: we can probably replace this component with <Invoice poll>
export default function FullInvoice () { export default function FullInvoice () {
const router = useRouter() const router = useRouter()
const { data, error } = useQuery(INVOICE, SSR
? {}
: {
pollInterval: FAST_POLL_INTERVAL,
variables: { id: router.query.id },
nextFetchPolicy: 'cache-and-network'
})
return ( return (
<CenterLayout> <CenterLayout>
{error && <div>{error.toString()}</div>} <Invoice id={router.query.id} query={INVOICE_FULL} poll description status='loading' bolt11Info webLn={false} />
{data ? <Invoice invoice={data.invoice} /> : <QrSkeleton description status='loading' bolt11Info />}
</CenterLayout> </CenterLayout>
) )
} }

View File

@ -27,7 +27,7 @@ export default function PostEdit ({ ssrData }) {
const { item } = data || ssrData const { item } = data || ssrData
const [sub, setSub] = useState(item.subName) const [sub, setSub] = useState(item.subName)
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 const editThreshold = new Date(item?.invoice?.confirmedAt ?? item.createdAt).getTime() + 10 * 60000
let FormType = DiscussionForm let FormType = DiscussionForm
let itemType = 'DISCUSSION' let itemType = 'DISCUSSION'

View File

@ -4,7 +4,7 @@ import InputGroup from 'react-bootstrap/InputGroup'
import { getGetServerSideProps } from '@/api/ssrApollo' import { getGetServerSideProps } from '@/api/ssrApollo'
import { Form, Input, SubmitButton } from '@/components/form' import { Form, Input, SubmitButton } from '@/components/form'
import Layout from '@/components/layout' import Layout from '@/components/layout'
import { useMutation, useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import Link from 'next/link' import Link from 'next/link'
import { amountSchema } from '@/lib/validate' import { amountSchema } from '@/lib/validate'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
@ -21,6 +21,8 @@ import { useData } from '@/components/use-data'
import { GrowthPieChartSkeleton } from '@/components/charts-skeletons' import { GrowthPieChartSkeleton } from '@/components/charts-skeletons'
import { useMemo } from 'react' import { useMemo } from 'react'
import { CompactLongCountdown } from '@/components/countdown' import { CompactLongCountdown } from '@/components/countdown'
import { usePaidMutation } from '@/components/use-paid-mutation'
import { DONATE } from '@/fragments/paidAction'
const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), { const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), {
loading: () => <GrowthPieChartSkeleton /> loading: () => <GrowthPieChartSkeleton />
@ -159,11 +161,7 @@ export function DonateButton () {
const showModal = useShowModal() const showModal = useShowModal()
const toaster = useToast() const toaster = useToast()
const strike = useLightning() const strike = useLightning()
const [donateToRewards] = useMutation( const [donateToRewards] = usePaidMutation(DONATE)
gql`
mutation donateToRewards($sats: Int!, $hash: String, $hmac: String) {
donateToRewards(sats: $sats, hash: $hash, hmac: $hmac)
}`)
return ( return (
<> <>
@ -174,25 +172,20 @@ export function DonateButton () {
amount: 10000 amount: 10000
}} }}
schema={amountSchema} schema={amountSchema}
prepaid
onSubmit={async ({ amount, hash, hmac }) => { onSubmit={async ({ amount, hash, hmac }) => {
const { error } = await donateToRewards({ const { error } = await donateToRewards({
variables: { variables: {
sats: Number(amount), sats: Number(amount),
hash, hash,
hmac hmac
} },
}) onCompleted: () => {
if (error) { strike()
console.error(error)
toaster.danger('failed to donate')
} else {
const didStrike = strike()
if (!didStrike) {
toaster.success('donated') toaster.success('donated')
} }
} })
onClose() onClose()
if (error) throw error
}} }}
> >
<Input <Input

View File

@ -0,0 +1,783 @@
-- CreateEnum
CREATE TYPE "InvoiceActionType" AS ENUM ('BUY_CREDITS', 'ITEM_CREATE', 'ITEM_UPDATE', 'ZAP', 'DOWN_ZAP', 'DONATE', 'POLL_VOTE', 'TERRITORY_CREATE', 'TERRITORY_UPDATE', 'TERRITORY_BILLING', 'TERRITORY_UNARCHIVE');
-- CreateEnum
CREATE TYPE "InvoiceActionState" AS ENUM ('PENDING', 'PENDING_HELD', 'HELD', 'PAID', 'FAILED', 'RETRYING');
-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "actionState" "InvoiceActionState",
ADD COLUMN "actionType" "InvoiceActionType",
ADD COLUMN "actionId" INTEGER;
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "invoiceActionState" "InvoiceActionState",
ADD COLUMN "invoiceId" INTEGER,
ADD COLUMN "invoicePaidAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "ItemAct" ADD COLUMN "invoiceActionState" "InvoiceActionState",
ADD COLUMN "invoiceId" INTEGER;
-- AlterTable
ALTER TABLE "PollVote" ADD COLUMN "invoiceActionState" "InvoiceActionState",
ADD COLUMN "invoiceId" INTEGER;
-- AlterTable
ALTER TABLE "Upload" ADD COLUMN "invoiceActionState" "InvoiceActionState",
ADD COLUMN "invoiceId" INTEGER;
-- AlterTable
ALTER TABLE "PollBlindVote" ADD COLUMN "invoiceActionState" "InvoiceActionState",
ADD COLUMN "invoiceId" INTEGER;
-- AddForeignKey
ALTER TABLE "PollBlindVote" ADD CONSTRAINT "PollBlindVote_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Upload" ADD CONSTRAINT "Upload_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Item" ADD CONSTRAINT "Item_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemAct" ADD CONSTRAINT "ItemAct_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX "Item_invoiceId_idx" ON "Item"("invoiceId");
-- CreateIndex
CREATE INDEX "ItemAct_invoiceId_idx" ON "ItemAct"("invoiceId");
-- CreateIndex
CREATE INDEX "PollVote_invoiceId_idx" ON "PollVote"("invoiceId");
-- CreateIndex
CREATE INDEX "Upload_invoiceId_idx" ON "Upload"("invoiceId");
-- CreateIndex
CREATE INDEX "Withdrawl_hash_idx" ON "Withdrawl"("hash");
-- CreateTable
CREATE TABLE "ItemUserAgg" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"itemId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"zapSats" BIGINT NOT NULL DEFAULT 0,
"downZapSats" BIGINT NOT NULL DEFAULT 0,
CONSTRAINT "ItemUserAgg_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ItemUserAgg_itemId_idx" ON "ItemUserAgg"("itemId");
-- CreateIndex
CREATE INDEX "ItemUserAgg_userId_idx" ON "ItemUserAgg"("userId");
-- CreateIndex
CREATE INDEX "ItemUserAgg_created_at_idx" ON "ItemUserAgg"("created_at");
-- CreateIndex
CREATE UNIQUE INDEX "ItemUserAgg_itemId_userId_key" ON "ItemUserAgg"("itemId", "userId");
-- catch up existing data
INSERT INTO "ItemUserAgg" ("itemId", "userId", "zapSats", "downZapSats", "created_at", "updated_at")
SELECT "ItemAct"."itemId", "ItemAct"."userId",
COALESCE(sum("ItemAct"."msats") FILTER(WHERE act = 'TIP' OR act = 'FEE') / 1000.0, 0) as "zapSats",
COALESCE(sum("ItemAct"."msats") FILTER(WHERE act = 'DONT_LIKE_THIS') / 1000.0, 0) as "downZapSats",
min("ItemAct"."created_at"), max("ItemAct"."created_at")
FROM "ItemAct"
JOIN "Item" ON "Item"."id" = "ItemAct"."itemId" AND "Item"."userId" <> "ItemAct"."userId"
WHERE act IN ('TIP', 'FEE', 'DONT_LIKE_THIS')
GROUP BY "ItemAct"."itemId", "ItemAct"."userId";
-- AddForeignKey
ALTER TABLE "ItemUserAgg" ADD CONSTRAINT "ItemUserAgg_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemUserAgg" ADD CONSTRAINT "ItemUserAgg_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- we do this explicitly now
DROP TRIGGER IF EXISTS timestamp_item_on_insert ON "Item";
DROP FUNCTION IF EXISTS timestamp_item_on_insert;
-- we do this explicitly now
DROP TRIGGER IF EXISTS ncomments_after_comment_trigger ON "Item";
DROP FUNCTION IF EXISTS ncomments_after_comment;
-- don't index items unless they are paid
DROP TRIGGER IF EXISTS index_item ON "Item";
CREATE TRIGGER index_item
AFTER INSERT OR UPDATE ON "Item"
FOR EACH ROW
WHEN (NEW."invoiceActionState" IS NULL OR NEW."invoiceActionState" = 'PAID')
EXECUTE PROCEDURE index_item();
-- XXX these drops are backwards incompatible
-- we do this explicitly now
DROP FUNCTION IF EXISTS bounty_paid_after_act;
-- we are removing referrals temporarily
DROP FUNCTION IF EXISTS referral_act;
-- we do all these explicitly in js now
DROP FUNCTION IF EXISTS sats_after_tip;
DROP FUNCTION IF EXISTS weighted_votes_after_tip;
DROP FUNCTION IF EXISTS weighted_downvotes_after_act;
DROP FUNCTION IF EXISTS poll_vote;
DROP FUNCTION IF EXISTS item_act;
DROP FUNCTION IF EXISTS create_item;
DROP FUNCTION IF EXISTS update_item(jitem jsonb, forward jsonb, poll_options jsonb);
DROP FUNCTION IF EXISTS update_item(jitem jsonb, forward jsonb, poll_options jsonb, upload_ids integer[]);
DROP FUNCTION IF EXISTS create_poll(jitem jsonb, poll_options jsonb);
DROP FUNCTION IF EXISTS donate;
-- this is dead code
DROP FUNCTION IF EXISTS create_withdrawl(lnd_id TEXT, invoice TEXT, msats_amount BIGINT, msats_max_fee BIGINT, username TEXT, auto_withdraw BOOLEAN);
-- dont call nonexistant item_act ... we'll eventually replace this
CREATE OR REPLACE FUNCTION run_auction(item_id INTEGER) RETURNS void AS $$
DECLARE
bid_sats INTEGER;
user_msats BIGINT;
user_id INTEGER;
item_status "Status";
status_updated_at timestamp(3);
BEGIN
PERFORM ASSERT_SERIALIZED();
-- extract data we need
SELECT "maxBid", "userId", status, "statusUpdatedAt"
INTO bid_sats, user_id, item_status, status_updated_at
FROM "Item"
WHERE id = item_id;
SELECT msats INTO user_msats FROM users WHERE id = user_id;
-- 0 bid items expire after 30 days unless updated
IF bid_sats = 0 THEN
IF item_status <> 'STOPPED' THEN
IF status_updated_at < now_utc() - INTERVAL '30 days' THEN
UPDATE "Item" SET status = 'STOPPED', "statusUpdatedAt" = now_utc() WHERE id = item_id;
ELSEIF item_status = 'NOSATS' THEN
UPDATE "Item" SET status = 'ACTIVE' WHERE id = item_id;
END IF;
END IF;
RETURN;
END IF;
-- check if user wallet has enough sats
IF bid_sats * 1000 > user_msats THEN
-- if not, set status = NOSATS and statusUpdatedAt to now_utc if not already set
IF item_status <> 'NOSATS' THEN
UPDATE "Item" SET status = 'NOSATS', "statusUpdatedAt" = now_utc() WHERE id = item_id;
ELSEIF status_updated_at < now_utc() - INTERVAL '30 days' THEN
UPDATE "Item" SET status = 'STOPPED', "statusUpdatedAt" = now_utc() WHERE id = item_id;
END IF;
ELSE
UPDATE users SET msats = msats - (bid_sats * 1000) WHERE id = user_id;
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (bid_sats * 1000, item_id, user_id, 'STREAM', now_utc(), now_utc());
-- update item status = ACTIVE and statusUpdatedAt = now_utc if NOSATS
IF item_status = 'NOSATS' THEN
UPDATE "Item" SET status = 'ACTIVE', "statusUpdatedAt" = now_utc() WHERE id = item_id;
END IF;
END IF;
END;
$$ LANGUAGE plpgsql;
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", '
|| ' "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 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" <> $5) AS "mePendingMsats", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
|| ' FROM "ItemAct" '
|| ' 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
$$;
DROP MATERIALIZED VIEW IF EXISTS zap_rank_personal_view;
CREATE MATERIALIZED VIEW IF NOT EXISTS zap_rank_personal_view AS
WITH item_votes AS (
SELECT "Item".id, "Item"."parentId", "Item".boost, "Item".created_at, "Item"."weightedComments", "ItemAct"."userId" AS "voterId",
LOG((SUM("ItemAct".msats) FILTER (WHERE "ItemAct".act IN ('TIP', 'FEE'))) / 1000.0) AS "vote",
GREATEST(LOG((SUM("ItemAct".msats) FILTER (WHERE "ItemAct".act = 'DONT_LIKE_THIS')) / 1000.0), 0) AS "downVote"
FROM "Item"
CROSS JOIN zap_rank_personal_constants
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
WHERE (
("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
AND
(
("ItemAct"."userId" <> "Item"."userId" AND "ItemAct".act IN ('TIP', 'FEE', 'DONT_LIKE_THIS'))
OR
("ItemAct".act = 'BOOST' AND "ItemAct"."userId" = "Item"."userId")
)
)
AND "Item".created_at >= now_utc() - item_age_bound
GROUP BY "Item".id, "Item"."parentId", "Item".boost, "Item".created_at, "Item"."weightedComments", "ItemAct"."userId"
HAVING SUM("ItemAct".msats) > 1000
), viewer_votes AS (
SELECT item_votes.id, item_votes."parentId", item_votes.boost, item_votes.created_at,
item_votes."weightedComments", "Arc"."fromId" AS "viewerId",
GREATEST("Arc"."zapTrust", g."zapTrust", 0) * item_votes."vote" AS "weightedVote",
GREATEST("Arc"."zapTrust", g."zapTrust", 0) * item_votes."downVote" AS "weightedDownVote"
FROM item_votes
CROSS JOIN zap_rank_personal_constants
LEFT JOIN "Arc" ON "Arc"."toId" = item_votes."voterId"
LEFT JOIN "Arc" g ON g."fromId" = global_viewer_id AND g."toId" = item_votes."voterId"
AND ("Arc"."zapTrust" IS NOT NULL OR g."zapTrust" IS NOT NULL)
), viewer_weighted_votes AS (
SELECT viewer_votes.id, viewer_votes."parentId", viewer_votes.boost, viewer_votes.created_at, viewer_votes."viewerId",
viewer_votes."weightedComments", SUM(viewer_votes."weightedVote") AS "weightedVotes",
SUM(viewer_votes."weightedDownVote") AS "weightedDownVotes"
FROM viewer_votes
GROUP BY viewer_votes.id, viewer_votes."parentId", viewer_votes.boost, viewer_votes.created_at, viewer_votes."viewerId", viewer_votes."weightedComments"
), viewer_zaprank AS (
SELECT l.id, l."parentId", l.boost, l.created_at, l."viewerId", l."weightedComments",
GREATEST(l."weightedVotes", g."weightedVotes") AS "weightedVotes", GREATEST(l."weightedDownVotes", g."weightedDownVotes") AS "weightedDownVotes"
FROM viewer_weighted_votes l
CROSS JOIN zap_rank_personal_constants
JOIN users ON users.id = l."viewerId"
JOIN viewer_weighted_votes g ON l.id = g.id AND g."viewerId" = global_viewer_id
WHERE (l."weightedVotes" > min_viewer_votes
AND g."weightedVotes" / l."weightedVotes" <= max_personal_viewer_vote_ratio
AND users."lastSeenAt" >= now_utc() - user_last_seen_bound)
OR l."viewerId" = global_viewer_id
GROUP BY l.id, l."parentId", l.boost, l.created_at, l."viewerId", l."weightedVotes", l."weightedComments",
g."weightedVotes", l."weightedDownVotes", g."weightedDownVotes", min_viewer_votes
HAVING GREATEST(l."weightedVotes", g."weightedVotes") > min_viewer_votes OR l.boost > 0
), viewer_fractions_zaprank AS (
SELECT z.*,
(CASE WHEN z."weightedVotes" - z."weightedDownVotes" > 0 THEN
GREATEST(z."weightedVotes" - z."weightedDownVotes", POWER(z."weightedVotes" - z."weightedDownVotes", vote_power))
ELSE
z."weightedVotes" - z."weightedDownVotes"
END + z."weightedComments" * CASE WHEN z."parentId" IS NULL THEN comment_scaler ELSE 0 END) AS tf_numerator,
POWER(GREATEST(age_wait_hours, EXTRACT(EPOCH FROM (now_utc() - z.created_at))/3600), vote_decay) AS decay_denominator,
(POWER(z.boost/boost_per_vote, boost_power)
/
POWER(GREATEST(age_wait_hours, EXTRACT(EPOCH FROM (now_utc() - z.created_at))/3600), boost_decay)) AS boost_addend
FROM viewer_zaprank z, zap_rank_personal_constants
)
SELECT z.id, z."parentId", z."viewerId",
COALESCE(tf_numerator, 0) / decay_denominator + boost_addend AS tf_hot_score,
COALESCE(tf_numerator, 0) AS tf_top_score
FROM viewer_fractions_zaprank z
WHERE tf_numerator > 0 OR boost_addend > 0;
CREATE UNIQUE INDEX IF NOT EXISTS zap_rank_personal_view_viewer_id_idx ON zap_rank_personal_view("viewerId", id);
CREATE INDEX IF NOT EXISTS hot_tf_zap_rank_personal_view_idx ON zap_rank_personal_view("viewerId", tf_hot_score DESC NULLS LAST, id DESC);
CREATE INDEX IF NOT EXISTS top_tf_zap_rank_personal_view_idx ON zap_rank_personal_view("viewerId", tf_top_score DESC NULLS LAST, id DESC);
CREATE OR REPLACE FUNCTION rewards(min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT)
RETURNS TABLE (
t TIMESTAMP(3), total BIGINT, donations BIGINT, fees BIGINT, boost BIGINT, jobs BIGINT, anons_stack BIGINT
)
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
RETURN QUERY
SELECT period.t,
coalesce(FLOOR(sum(msats)), 0)::BIGINT as total,
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'DONATION')), 0)::BIGINT as donations,
coalesce(FLOOR(sum(msats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION', 'ANON'))), 0)::BIGINT as fees,
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'BOOST')), 0)::BIGINT as boost,
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'STREAM')), 0)::BIGINT as jobs,
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'ANON')), 0)::BIGINT as anons_stack
FROM generate_series(min, max, ival) period(t),
LATERAL
(
(SELECT
("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) * COALESCE("Sub"."rewardsPct", 100) * 0.01 as msats,
act::text as type
FROM "ItemAct"
JOIN "Item" ON "Item"."id" = "ItemAct"."itemId"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
LEFT JOIN "ReferralAct" ON "ReferralAct"."itemActId" = "ItemAct".id
WHERE date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
AND "ItemAct".act <> 'TIP'
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'))
UNION ALL
(SELECT sats * 1000 as msats, 'DONATION' as type
FROM "Donation"
WHERE date_trunc(date_part, "Donation".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t)
UNION ALL
-- any earnings from anon's stack that are not forwarded to other users
(SELECT "ItemAct".msats, 'ANON' as type
FROM "Item"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id
WHERE "Item"."userId" = 27 AND "ItemAct".act = 'TIP'
AND date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
GROUP BY "ItemAct".id, "ItemAct".msats
HAVING COUNT("ItemForward".id) = 0)
) x
GROUP BY period.t;
END;
$$;
CREATE OR REPLACE FUNCTION user_values(
min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT,
percentile_cutoff INTEGER DEFAULT 33,
each_upvote_portion FLOAT DEFAULT 4.0,
each_item_portion FLOAT DEFAULT 4.0,
handicap_ids INTEGER[] DEFAULT '{616, 6030, 946, 4502}',
handicap_zap_mult FLOAT DEFAULT 0.2)
RETURNS TABLE (
t TIMESTAMP(3), id INTEGER, proportion FLOAT
)
LANGUAGE plpgsql
AS $$
DECLARE
min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
BEGIN
RETURN QUERY
SELECT period.t, u."userId", u.total_proportion
FROM generate_series(min, max, ival) period(t),
LATERAL
(WITH item_ratios AS (
SELECT *,
CASE WHEN "parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type,
CASE WHEN "weightedVotes" > 0 THEN "weightedVotes"/(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL)) ELSE 0 END AS ratio
FROM (
SELECT *,
NTILE(100) OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS percentile,
ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS rank
FROM
"Item"
WHERE date_trunc(date_part, created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
AND "weightedVotes" > 0
AND "deletedAt" IS NULL
AND NOT bio
AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID')
) x
WHERE x.percentile <= percentile_cutoff
),
-- get top upvoters of top posts and comments
upvoter_islands AS (
SELECT "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId",
"ItemAct".msats as tipped, "ItemAct".created_at as acted_at,
ROW_NUMBER() OVER (partition by item_ratios.id order by "ItemAct".created_at asc)
- ROW_NUMBER() OVER (partition by item_ratios.id, "ItemAct"."userId" order by "ItemAct".created_at asc) AS island
FROM item_ratios
JOIN "ItemAct" on "ItemAct"."itemId" = item_ratios.id
WHERE act = 'TIP'
AND date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
),
-- isolate contiguous upzaps from the same user on the same item so that when we take the log
-- of the upzaps it accounts for successive zaps and does not disproportionately reward them
upvoters AS (
SELECT "userId", upvoter_islands.id, ratio, "parentId", GREATEST(log(sum(tipped) / 1000), 0) as tipped, min(acted_at) as acted_at
FROM upvoter_islands
GROUP BY "userId", upvoter_islands.id, ratio, "parentId", island
),
-- the relative contribution of each upvoter to the post/comment
-- early multiplier: 10/ln(early_rank + e)
-- we also weight by trust in a step wise fashion
upvoter_ratios AS (
SELECT "userId", sum(early_multiplier*tipped_ratio*ratio*CASE WHEN users.id = ANY (handicap_ids) THEN handicap_zap_mult ELSE FLOOR(users.trust*3)+handicap_zap_mult END) as upvoter_ratio,
"parentId" IS NULL as "isPost", CASE WHEN "parentId" IS NULL THEN 'TIP_POST' ELSE 'TIP_COMMENT' END as type
FROM (
SELECT *,
10.0/LN(ROW_NUMBER() OVER (partition by upvoters.id order by acted_at asc) + EXP(1.0)) AS early_multiplier,
tipped::float/(sum(tipped) OVER (partition by upvoters.id)) tipped_ratio
FROM upvoters
WHERE tipped > 0
) u
JOIN users on "userId" = users.id
GROUP BY "userId", "parentId" IS NULL
),
proportions AS (
SELECT "userId", NULL as id, type, ROW_NUMBER() OVER (PARTITION BY "isPost" ORDER BY upvoter_ratio DESC) as rank,
upvoter_ratio/(sum(upvoter_ratio) OVER (PARTITION BY "isPost"))/each_upvote_portion as proportion
FROM upvoter_ratios
WHERE upvoter_ratio > 0
UNION ALL
SELECT "userId", item_ratios.id, type, rank, ratio/each_item_portion as proportion
FROM item_ratios
)
SELECT "userId", sum(proportions.proportion) AS total_proportion
FROM proportions
GROUP BY "userId"
HAVING sum(proportions.proportion) > 0.000001) u;
END;
$$;
CREATE OR REPLACE FUNCTION sub_stats(min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT)
RETURNS TABLE (
t TIMESTAMP(3), sub_name CITEXT, comments BIGINT, posts BIGINT,
msats_revenue BIGINT, msats_stacked BIGINT, msats_spent BIGINT)
LANGUAGE plpgsql
AS $$
DECLARE
min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
BEGIN
RETURN QUERY
SELECT period.t,
"subName" as sub_name,
(sum(quantity) FILTER (WHERE type = 'COMMENT'))::BIGINT as comments,
(sum(quantity) FILTER (WHERE type = 'POST'))::BIGINT as posts,
(sum(quantity) FILTER (WHERE type = 'REVENUE'))::BIGINT as msats_revenue,
(sum(quantity) FILTER (WHERE type = 'TIP'))::BIGINT as msats_stacked,
(sum(quantity) FILTER (WHERE type IN ('BOOST', 'TIP', 'FEE', 'STREAM', 'POLL', 'DONT_LIKE_THIS', 'VOTE')))::BIGINT as msats_spent
FROM generate_series(min, max, ival) period(t)
LEFT JOIN (
-- For msats_spent and msats_stacked
(SELECT COALESCE("Item"."subName", root."subName") as "subName", "ItemAct"."msats" as quantity, act::TEXT as type, "ItemAct"."created_at"
FROM "ItemAct"
JOIN "Item" ON "Item"."id" = "ItemAct"."itemId"
LEFT JOIN "Item" root ON "Item"."rootId" = root.id
WHERE "ItemAct"."created_at" >= min_utc
AND ("Item"."subName" IS NOT NULL OR root."subName" IS NOT NULL)
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'))
UNION ALL
(SELECT "subName", 1 as quantity, 'POST' as type, created_at
FROM "Item"
WHERE created_at >= min_utc
AND "Item"."parentId" IS NULL
AND "subName" IS NOT NULL)
UNION ALL
(SELECT root."subName", 1 as quantity, 'COMMENT' as type, "Item"."created_at"
FROM "Item"
JOIN "Item" root ON "Item"."rootId" = root."id"
WHERE "Item"."created_at" >= min_utc
AND root."subName" IS NOT NULL
AND "Item"."parentId" IS NOT NULL)
UNION ALL
-- For msats_revenue
(SELECT "subName", msats as quantity, type::TEXT as type, created_at
FROM "SubAct"
WHERE created_at >= min_utc)
) u ON period.t = date_trunc(date_part, u.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')
GROUP BY "subName", period.t
ORDER BY period.t ASC;
END;
$$;
CREATE OR REPLACE FUNCTION user_stats(min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT)
RETURNS TABLE (
t TIMESTAMP(3), id INTEGER, comments BIGINT, posts BIGINT, territories BIGINT,
referrals BIGINT, msats_tipped BIGINT, msats_rewards BIGINT, msats_referrals BIGINT,
msats_revenue BIGINT, msats_stacked BIGINT, msats_fees BIGINT, msats_donated BIGINT,
msats_billing BIGINT, msats_spent BIGINT)
LANGUAGE plpgsql
AS $$
DECLARE
min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
BEGIN
RETURN QUERY
SELECT period.t,
"userId" as id,
-- counts
(sum(quantity) FILTER (WHERE type = 'COMMENT'))::BIGINT as comments,
(sum(quantity) FILTER (WHERE type = 'POST'))::BIGINT as posts,
(sum(quantity) FILTER (WHERE type = 'TERRITORY'))::BIGINT as territories,
(sum(quantity) FILTER (WHERE type = 'REFERRAL'))::BIGINT as referrals,
-- stacking
(sum(quantity) FILTER (WHERE type = 'TIPPEE'))::BIGINT as msats_tipped,
(sum(quantity) FILTER (WHERE type = 'EARN'))::BIGINT as msats_rewards,
(sum(quantity) FILTER (WHERE type = 'REFERRAL_ACT'))::BIGINT as msats_referrals,
(sum(quantity) FILTER (WHERE type = 'REVENUE'))::BIGINT as msats_revenue,
(sum(quantity) FILTER (WHERE type IN ('TIPPEE', 'EARN', 'REFERRAL_ACT', 'REVENUE')))::BIGINT as msats_stacked,
-- spending
(sum(quantity) FILTER (WHERE type IN ('BOOST', 'TIP', 'FEE', 'STREAM', 'POLL', 'DONT_LIKE_THIS')))::BIGINT as msats_fees,
(sum(quantity) FILTER (WHERE type = 'DONATION'))::BIGINT as msats_donated,
(sum(quantity) FILTER (WHERE type = 'BILLING'))::BIGINT as msats_billing,
(sum(quantity) FILTER (WHERE type IN ('BOOST', 'TIP', 'FEE', 'STREAM', 'POLL', 'DONT_LIKE_THIS', 'DONATION', 'BILLING')))::BIGINT as msats_spent
FROM generate_series(min, max, ival) period(t)
LEFT JOIN
((SELECT "userId", msats as quantity, act::TEXT as type, created_at
FROM "ItemAct"
WHERE created_at >= min_utc
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'))
UNION ALL
(SELECT "userId", sats*1000 as quantity, 'DONATION' as type, created_at
FROM "Donation"
WHERE created_at >= min_utc)
UNION ALL
(SELECT "userId", 1 as quantity,
CASE WHEN "Item"."parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type, created_at
FROM "Item"
WHERE created_at >= min_utc)
UNION ALL
(SELECT "referrerId" as "userId", 1 as quantity, 'REFERRAL' as type, created_at
FROM users
WHERE "referrerId" IS NOT NULL
AND created_at >= min_utc)
UNION ALL
-- tips accounting for forwarding
(SELECT "Item"."userId", floor("ItemAct".msats * (1-COALESCE(sum("ItemForward".pct)/100.0, 0))) as quantity, 'TIPPEE' as type, "ItemAct".created_at
FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
LEFT JOIN "ItemForward" on "ItemForward"."itemId" = "Item".id
WHERE "ItemAct".act = 'TIP'
AND "ItemAct".created_at >= min_utc
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
GROUP BY "Item"."userId", "ItemAct".id, "ItemAct".msats, "ItemAct".created_at)
UNION ALL
-- tips where stacker is a forwardee
(SELECT "ItemForward"."userId", floor("ItemAct".msats*("ItemForward".pct/100.0)) as quantity, 'TIPPEE' as type, "ItemAct".created_at
FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
JOIN "ItemForward" on "ItemForward"."itemId" = "Item".id
WHERE "ItemAct".act = 'TIP'
AND "ItemAct".created_at >= min_utc
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'))
UNION ALL
(SELECT "userId", msats as quantity, 'EARN' as type, created_at
FROM "Earn"
WHERE created_at >= min_utc)
UNION ALL
(SELECT "referrerId" as "userId", msats as quantity, 'REFERRAL_ACT' as type, created_at
FROM "ReferralAct"
WHERE created_at >= min_utc)
UNION ALL
(SELECT "userId", msats as quantity, type::TEXT as type, created_at
FROM "SubAct"
WHERE created_at >= min_utc)
UNION ALL
(SELECT "userId", 1 as quantity, 'TERRITORY' as type, created_at
FROM "Sub"
WHERE status <> 'STOPPED'
AND created_at >= min_utc)
) u ON period.t = date_trunc(date_part, u.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')
GROUP BY "userId", period.t
ORDER BY period.t ASC;
END;
$$;
CREATE OR REPLACE FUNCTION spending_growth(min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT)
RETURNS TABLE (t TIMESTAMP(3), jobs BIGINT, boost BIGINT, fees BIGINT, tips BIGINT, donations BIGINT, territories BIGINT)
LANGUAGE plpgsql
AS $$
DECLARE
min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
BEGIN
RETURN QUERY
SELECT period.t,
coalesce(floor(sum(msats) FILTER (WHERE act = 'STREAM')/1000), 0)::BIGINT as jobs,
coalesce(floor(sum(msats) FILTER (WHERE act = 'BOOST')/1000), 0)::BIGINT as boost,
coalesce(floor(sum(msats) FILTER (WHERE act NOT IN ('BOOST', 'TIP', 'STREAM', 'DONATION', 'TERRITORY'))/1000), 0)::BIGINT as fees,
coalesce(floor(sum(msats) FILTER (WHERE act = 'TIP')/1000), 0)::BIGINT as tips,
coalesce(floor(sum(msats) FILTER (WHERE act = 'DONATION')/1000), 0)::BIGINT as donations,
coalesce(floor(sum(msats) FILTER (WHERE act = 'TERRITORY')/1000), 0)::BIGINT as territories
FROM generate_series(min, max, ival) period(t)
LEFT JOIN
((SELECT "ItemAct".created_at, msats, act::text as act
FROM "ItemAct"
WHERE created_at >= min_utc
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'))
UNION ALL
(SELECT created_at, sats * 1000 as msats, 'DONATION' as act
FROM "Donation"
WHERE created_at >= min_utc)
UNION ALL
(SELECT created_at, msats, 'TERRITORY' as act
FROM "SubAct"
WHERE type = 'BILLING'
AND created_at >= min_utc)
) u ON period.t = date_trunc(date_part, u.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')
GROUP BY period.t
ORDER BY period.t ASC;
END;
$$;
CREATE OR REPLACE FUNCTION stacking_growth(min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT)
RETURNS TABLE (t TIMESTAMP(3), rewards BIGINT, posts BIGINT, comments BIGINT, referrals BIGINT, territories BIGINT)
LANGUAGE plpgsql
AS $$
DECLARE
min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
BEGIN
RETURN QUERY
SELECT period.t,
coalesce(floor(sum(airdrop)/1000),0)::BIGINT as rewards,
coalesce(floor(sum(post)/1000),0)::BIGINT as posts,
coalesce(floor(sum(comment)/1000),0)::BIGINT as comments,
coalesce(floor(sum(referral)/1000),0)::BIGINT as referrals,
coalesce(floor(sum(revenue)/1000),0)::BIGINT as territories
FROM generate_series(min, max, ival) period(t)
LEFT JOIN
((SELECT "ItemAct".created_at, 0 as airdrop,
CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".msats END as comment,
CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".msats ELSE 0 END as post,
0 as referral,
0 as revenue
FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE "ItemAct".act = 'TIP'
AND "ItemAct".created_at >= min_utc
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'))
UNION ALL
(SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, msats as referral, 0 as revenue
FROM "ReferralAct"
WHERE created_at >= min_utc)
UNION ALL
(SELECT created_at, msats as airdrop, 0 as post, 0 as comment, 0 as referral, 0 as revenue
FROM "Earn"
WHERE created_at >= min_utc)
UNION ALL
(SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, 0 as referral, msats as revenue
FROM "SubAct"
WHERE type = 'REVENUE'
AND created_at >= min_utc)
) u ON period.t = date_trunc(date_part, u.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')
GROUP BY period.t
ORDER BY period.t ASC;
END;
$$;
CREATE OR REPLACE FUNCTION stackers_growth(min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT)
RETURNS TABLE (t TIMESTAMP(3), "userId" INT, type TEXT)
LANGUAGE plpgsql
AS $$
DECLARE
min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
BEGIN
RETURN QUERY
SELECT period.t, u."userId", u.type
FROM generate_series(min, max, ival) period(t)
LEFT JOIN
((SELECT "ItemAct".created_at, "Item"."userId", CASE WHEN "Item"."parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type
FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE "ItemAct".act = 'TIP'
AND "ItemAct".created_at >= min_utc
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'))
UNION ALL
(SELECT created_at, "Earn"."userId", 'EARN' as type
FROM "Earn"
WHERE created_at >= min_utc)
UNION ALL
(SELECT created_at, "ReferralAct"."referrerId" as "userId", 'REFERRAL' as type
FROM "ReferralAct"
WHERE created_at >= min_utc)
UNION ALL
(SELECT created_at, "SubAct"."userId", 'REVENUE' as type
FROM "SubAct"
WHERE "SubAct".type = 'REVENUE'
AND created_at >= min_utc)
) u ON period.t = date_trunc(date_part, u.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')
GROUP BY period.t, u."userId", u.type
ORDER BY period.t ASC;
END;
$$;
CREATE OR REPLACE FUNCTION item_growth(min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT)
RETURNS TABLE (t TIMESTAMP(3), comments BIGINT, jobs BIGINT, posts BIGINT, territories BIGINT, zaps BIGINT)
LANGUAGE plpgsql
AS $$
DECLARE
min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
BEGIN
RETURN QUERY
SELECT period.t, count(*) FILTER (WHERE type = 'COMMENT') as comments,
count(*) FILTER (WHERE type = 'JOB') as jobs,
count(*) FILTER (WHERE type = 'POST') as posts,
count(*) FILTER (WHERE type = 'TERRITORY') as territories,
count(*) FILTER (WHERE type = 'ZAP') as zaps
FROM generate_series(min, max, ival) period(t)
LEFT JOIN
((SELECT created_at,
CASE
WHEN "subName" = 'jobs' THEN 'JOB'
WHEN "parentId" IS NULL THEN 'POST'
ELSE 'COMMENT' END as type
FROM "Item"
WHERE created_at >= min_utc
AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID'))
UNION ALL
(SELECT created_at, 'TERRITORY' as type
FROM "Sub"
WHERE created_at >= min_utc)
UNION ALL
(SELECT created_at, 'ZAP' as type
FROM "ItemAct"
WHERE act = 'TIP'
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
AND created_at >= min_utc)) u ON period.t = date_trunc(date_part, u.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')
GROUP BY period.t
ORDER BY period.t ASC;
END;
$$;
CREATE OR REPLACE FUNCTION spender_growth(min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT)
RETURNS TABLE (t TIMESTAMP(3), "userId" INT, type TEXT)
LANGUAGE plpgsql
AS $$
DECLARE
min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
BEGIN
RETURN QUERY
SELECT period.t, u."userId", u.type
FROM generate_series(min, max, ival) period(t)
LEFT JOIN
((SELECT "ItemAct".created_at, "ItemAct"."userId", act::text as type
FROM "ItemAct"
WHERE created_at >= min_utc
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'))
UNION ALL
(SELECT created_at, "Donation"."userId", 'DONATION' as type
FROM "Donation"
WHERE created_at >= min_utc)
UNION ALL
(SELECT created_at, "SubAct"."userId", 'TERRITORY' as type
FROM "SubAct"
WHERE "SubAct".type = 'BILLING'
AND created_at >= min_utc)
) u ON period.t = date_trunc(date_part, u.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')
GROUP BY period.t, u."userId", u.type
ORDER BY period.t ASC;
END;
$$;

View File

@ -126,6 +126,7 @@ model User {
walletLogs WalletLog[] walletLogs WalletLog[]
Reminder Reminder[] Reminder Reminder[]
PollBlindVote PollBlindVote[] PollBlindVote PollBlindVote[]
ItemUserAgg ItemUserAgg[]
@@index([photoId]) @@index([photoId])
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@ -285,21 +286,25 @@ model ItemUpload {
} }
model Upload { model Upload {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
type String type String
size Int size Int
width Int? width Int?
height Int? height Int?
userId Int userId Int
paid Boolean? paid Boolean?
user User @relation("Uploads", fields: [userId], references: [id], onDelete: Cascade) invoiceId Int?
User User[] invoiceActionState InvoiceActionState?
ItemUpload ItemUpload[] invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull)
user User @relation("Uploads", fields: [userId], references: [id], onDelete: Cascade)
User User[]
ItemUpload ItemUpload[]
@@index([createdAt], map: "Upload.created_at_index") @@index([createdAt], map: "Upload.created_at_index")
@@index([userId], map: "Upload.userId_index") @@index([userId], map: "Upload.userId_index")
@@index([invoiceId])
} }
model Earn { model Earn {
@ -359,75 +364,80 @@ model Message {
/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info. /// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info.
model Item { model Item {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
title String? title String?
text String? text String?
url String? url String?
userId Int userId Int
parentId Int? parentId Int?
path Unsupported("ltree")? path Unsupported("ltree")?
pinId Int? pinId Int?
latitude Float? latitude Float?
location String? location String?
longitude Float? longitude Float?
maxBid Int? maxBid Int?
maxSalary Int? maxSalary Int?
minSalary Int? minSalary Int?
remote Boolean? remote Boolean?
subName String? @db.Citext subName String? @db.Citext
statusUpdatedAt DateTime? statusUpdatedAt DateTime?
status Status @default(ACTIVE) status Status @default(ACTIVE)
company String? company String?
weightedVotes Float @default(0) weightedVotes Float @default(0)
boost Int @default(0) boost Int @default(0)
pollCost Int? pollCost Int?
paidImgLink Boolean @default(false) paidImgLink Boolean @default(false)
commentMsats BigInt @default(0) commentMsats BigInt @default(0)
lastCommentAt DateTime? lastCommentAt DateTime?
lastZapAt DateTime? lastZapAt DateTime?
ncomments Int @default(0) ncomments Int @default(0)
msats BigInt @default(0) msats BigInt @default(0)
weightedDownVotes Float @default(0) weightedDownVotes Float @default(0)
bio Boolean @default(false) bio Boolean @default(false)
freebie Boolean @default(false) freebie Boolean @default(false)
deletedAt DateTime? deletedAt DateTime?
otsFile Bytes? otsFile Bytes?
otsHash String? otsHash String?
imgproxyUrls Json? imgproxyUrls Json?
bounty Int? bounty Int?
noteId String? @unique(map: "Item.noteId_unique") noteId String? @unique(map: "Item.noteId_unique")
rootId Int? rootId Int?
bountyPaidTo Int[] bountyPaidTo Int[]
upvotes Int @default(0) upvotes Int @default(0)
weightedComments Float @default(0) weightedComments Float @default(0)
Bookmark Bookmark[] Bookmark Bookmark[]
parent Item? @relation("ParentChildren", fields: [parentId], references: [id]) parent Item? @relation("ParentChildren", fields: [parentId], references: [id])
children Item[] @relation("ParentChildren") children Item[] @relation("ParentChildren")
pin Pin? @relation(fields: [pinId], references: [id]) pin Pin? @relation(fields: [pinId], references: [id])
root Item? @relation("RootDescendant", fields: [rootId], references: [id]) root Item? @relation("RootDescendant", fields: [rootId], references: [id])
descendants Item[] @relation("RootDescendant") descendants Item[] @relation("RootDescendant")
sub Sub? @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade) sub Sub? @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade) user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade)
actions ItemAct[] itemActs ItemAct[]
mentions Mention[] mentions Mention[]
referrer ItemMention[] @relation("referrer") itemReferrers ItemMention[] @relation("referrer")
referee ItemMention[] @relation("referee") itemReferees ItemMention[] @relation("referee")
PollOption PollOption[] pollOptions PollOption[]
PollVote PollVote[] PollVote PollVote[]
ThreadSubscription ThreadSubscription[] threadSubscriptions ThreadSubscription[]
User User[] User User[]
itemForwards ItemForward[] itemForwards ItemForward[]
ItemUpload ItemUpload[] itemUploads ItemUpload[]
uploadId Int? uploadId Int?
outlawed Boolean @default(false) invoiceId Int?
apiKey Boolean @default(false) invoiceActionState InvoiceActionState?
pollExpiresAt DateTime? invoicePaidAt DateTime?
Ancestors Reply[] @relation("AncestorReplyItem") outlawed Boolean @default(false)
Replies Reply[] apiKey Boolean @default(false)
Reminder Reminder[] pollExpiresAt DateTime?
PollBlindVote PollBlindVote[] Ancestors Reply[] @relation("AncestorReplyItem")
Replies Reply[]
Reminder Reminder[]
invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull)
PollBlindVote PollBlindVote[]
ItemUserAgg ItemUserAgg[]
@@index([uploadId]) @@index([uploadId])
@@index([lastZapAt]) @@index([lastZapAt])
@ -446,6 +456,31 @@ model Item {
@@index([userId], map: "Item.userId_index") @@index([userId], map: "Item.userId_index")
@@index([weightedDownVotes], map: "Item.weightedDownVotes_index") @@index([weightedDownVotes], map: "Item.weightedDownVotes_index")
@@index([weightedVotes], map: "Item.weightedVotes_index") @@index([weightedVotes], map: "Item.weightedVotes_index")
@@index([invoiceId])
}
// we use this to denormalize a user's aggregated interactions (zaps) with an item
// we need to do this to safely modify the aggregates in a read committed transaction
// because we can't lock an aggregate query to guard a potentially conflicting update
// (e.g. sum("ItemAct".msats), but we can lock a row where we store the aggregate
// this is important because zaps do not update "weightedVotes" linearly
// see: https://stackoverflow.com/questions/61781595/postgres-read-commited-doesnt-re-read-updated-row?noredirect=1#comment109279507_61781595
// or: https://www.cybertec-postgresql.com/en/transaction-anomalies-with-select-for-update/
model ItemUserAgg {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
itemId Int
userId Int
zapSats BigInt @default(0)
downZapSats BigInt @default(0)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([itemId, userId])
@@index([itemId])
@@index([userId])
@@index([createdAt])
} }
// this is a denomalized table that is used to make reply notifications // this is a denomalized table that is used to make reply notifications
@ -502,25 +537,32 @@ model PollOption {
} }
model PollVote { model PollVote {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
itemId Int itemId Int
pollOptionId Int pollOptionId Int
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) invoiceId Int?
pollOption PollOption @relation(fields: [pollOptionId], references: [id], onDelete: Cascade) invoiceActionState InvoiceActionState?
invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
pollOption PollOption @relation(fields: [pollOptionId], references: [id], onDelete: Cascade)
@@index([pollOptionId], map: "PollVote.pollOptionId_index") @@index([pollOptionId], map: "PollVote.pollOptionId_index")
@@index([invoiceId])
} }
model PollBlindVote { model PollBlindVote {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
itemId Int itemId Int
userId Int userId Int
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) invoiceId Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) invoiceActionState InvoiceActionState?
invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([itemId, userId], map: "PollBlindVote.itemId_userId_unique") @@unique([itemId, userId], map: "PollBlindVote.itemId_userId_unique")
@@index([userId], map: "PollBlindVote.userId_index") @@index([userId], map: "PollBlindVote.userId_index")
@ -636,13 +678,17 @@ model ReferralAct {
} }
model ItemAct { model ItemAct {
id Int @id(map: "Vote_pkey") @default(autoincrement()) id Int @id(map: "Vote_pkey") @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
msats BigInt msats BigInt
act ItemActType act ItemActType
itemId Int itemId Int
userId Int userId Int
invoiceId Int?
invoiceActionState InvoiceActionState?
invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ReferralAct ReferralAct[] ReferralAct ReferralAct[]
@ -657,6 +703,7 @@ model ItemAct {
@@index([userId], map: "ItemAct.userId_index") @@index([userId], map: "ItemAct.userId_index")
@@index([itemId], map: "Vote.itemId_index") @@index([itemId], map: "Vote.itemId_index")
@@index([userId], map: "Vote.userId_index") @@index([userId], map: "Vote.userId_index")
@@index([invoiceId])
} }
model Mention { model Mention {
@ -674,14 +721,37 @@ model Mention {
@@index([userId], map: "Mention.userId_index") @@index([userId], map: "Mention.userId_index")
} }
enum InvoiceActionType {
BUY_CREDITS
ITEM_CREATE
ITEM_UPDATE
ZAP
DOWN_ZAP
DONATE
POLL_VOTE
TERRITORY_CREATE
TERRITORY_UPDATE
TERRITORY_BILLING
TERRITORY_UNARCHIVE
}
enum InvoiceActionState {
PENDING
PENDING_HELD
HELD
PAID
FAILED
RETRYING
}
model ItemMention { model ItemMention {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
referrerId Int referrerId Int
refereeId Int refereeId Int
referrerItem Item @relation("referrer", fields: [referrerId], references: [id], onDelete: Cascade) referrerItem Item @relation("referrer", fields: [referrerId], references: [id], onDelete: Cascade)
refereeItem Item @relation("referee", fields: [refereeId], references: [id], onDelete: Cascade) refereeItem Item @relation("referee", fields: [refereeId], references: [id], onDelete: Cascade)
@@unique([referrerId, refereeId], map: "ItemMention.referrerId_refereeId_unique") @@unique([referrerId, refereeId], map: "ItemMention.referrerId_refereeId_unique")
@@index([createdAt], map: "ItemMention.created_at_index") @@index([createdAt], map: "ItemMention.created_at_index")
@ -709,6 +779,15 @@ model Invoice {
lud18Data Json? lud18Data Json?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
actionState InvoiceActionState?
actionType InvoiceActionType?
actionId Int?
ItemAct ItemAct[]
Item Item[]
Upload Upload[]
PollVote PollVote[]
PollBlindVote PollBlindVote[]
@@index([createdAt], map: "Invoice.created_at_index") @@index([createdAt], map: "Invoice.created_at_index")
@@index([userId], map: "Invoice.userId_index") @@index([userId], map: "Invoice.userId_index")
@@index([confirmedIndex], map: "Invoice.confirmedIndex_index") @@index([confirmedIndex], map: "Invoice.confirmedIndex_index")
@ -733,6 +812,7 @@ model Withdrawl {
@@index([createdAt], map: "Withdrawl.created_at_index") @@index([createdAt], map: "Withdrawl.created_at_index")
@@index([userId], map: "Withdrawl.userId_index") @@index([userId], map: "Withdrawl.userId_index")
@@index([hash])
} }
model Account { model Account {

View File

@ -2,7 +2,7 @@ import PgBoss from 'pg-boss'
import nextEnv from '@next/env' import nextEnv from '@next/env'
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import { import {
autoDropBolt11s, checkPendingDeposits, checkPendingWithdrawals, autoDropBolt11s, checkInvoice, checkPendingDeposits, checkPendingWithdrawals,
finalizeHodlInvoice, subscribeToWallet finalizeHodlInvoice, subscribeToWallet
} from './wallet.js' } from './wallet.js'
import { repin } from './repin.js' import { repin } from './repin.js'
@ -25,6 +25,7 @@ import { ofac } from './ofac.js'
import { autoWithdraw } from './autowithdraw.js' import { autoWithdraw } from './autowithdraw.js'
import { saltAndHashEmails } from './saltAndHashEmails.js' import { saltAndHashEmails } from './saltAndHashEmails.js'
import { remindUser } from './reminder.js' import { remindUser } from './reminder.js'
import { holdAction, settleAction, settleActionError } from './paidAction.js'
const { loadEnvConfig } = nextEnv const { loadEnvConfig } = nextEnv
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
@ -104,6 +105,10 @@ async function work () {
await boss.work('ofac', jobWrapper(ofac)) await boss.work('ofac', jobWrapper(ofac))
await boss.work('saltAndHashEmails', jobWrapper(saltAndHashEmails)) await boss.work('saltAndHashEmails', jobWrapper(saltAndHashEmails))
await boss.work('reminder', jobWrapper(remindUser)) await boss.work('reminder', jobWrapper(remindUser))
await boss.work('settleActionError', jobWrapper(settleActionError))
await boss.work('settleAction', jobWrapper(settleAction))
await boss.work('holdAction', jobWrapper(holdAction))
await boss.work('checkInvoice', jobWrapper(checkInvoice))
console.log('working jobs') console.log('working jobs')
} }

133
worker/paidAction.js Normal file
View File

@ -0,0 +1,133 @@
import { paidActions } from '@/api/paidAction'
import { datePivot } from '@/lib/time'
import { Prisma } from '@prisma/client'
import { getInvoice } from 'ln-service'
async function transitionInvoice (jobName, { invoiceId, fromState, toState, toData, onTransition }, { models, lnd, boss }) {
console.group(`${jobName}: transitioning invoice ${invoiceId} from ${fromState} to ${toState}`)
let dbInvoice
try {
console.log('fetching invoice from db')
dbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } })
const lndInvoice = await getInvoice({ id: dbInvoice.hash, lnd })
const data = toData(lndInvoice)
if (!Array.isArray(fromState)) {
fromState = [fromState]
}
console.log('invoice is in state', dbInvoice.actionState)
await models.$transaction(async tx => {
dbInvoice = await tx.invoice.update({
where: {
id: invoiceId,
actionState: {
in: fromState
}
},
data: {
actionState: toState,
...data
}
})
// our own optimistic concurrency check
if (!dbInvoice) {
console.log('record not found, assuming concurrent worker transitioned it')
return
}
await onTransition({ lndInvoice, dbInvoice, tx })
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
console.log('transition succeeded')
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') {
console.log('record not found, assuming concurrent worker transitioned it')
return
}
if (e.code === 'P2034') {
console.log('write conflict, assuming concurrent worker is transitioning it')
return
}
}
console.error('unexpected error', e)
boss.send(
jobName,
{ invoiceId },
{ startAfter: datePivot(new Date(), { minutes: 1 }), priority: 1000 })
} finally {
console.groupEnd()
}
}
export async function settleAction ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('settleAction', {
invoiceId,
fromState: ['HELD', 'PENDING'],
toState: 'PAID',
toData: invoice => {
if (!invoice.is_confirmed) {
throw new Error('invoice is not confirmed')
}
return {
confirmedAt: new Date(invoice.confirmed_at),
confirmedIndex: invoice.confirmed_index,
msatsReceived: BigInt(invoice.received_mtokens)
}
},
onTransition: async ({ dbInvoice, tx }) => {
await paidActions[dbInvoice.actionType].onPaid?.({ invoice: dbInvoice }, { models, tx, lnd })
await tx.$executeRaw`INSERT INTO pgboss.job (name, data) VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}))`
}
}, { models, lnd, boss })
}
export async function holdAction ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('holdAction', {
invoiceId,
fromState: 'PENDING_HELD',
toState: 'HELD',
toData: invoice => {
if (!invoice.is_held) {
throw new Error('invoice is not held')
}
return {
isHeld: true,
msatsReceived: BigInt(invoice.received_mtokens)
}
},
onTransition: async ({ dbInvoice, tx }) => {
// make sure settled or cancelled in 60 seconds to minimize risk of force closures
const expiresAt = new Date(Math.min(dbInvoice.expiresAt, datePivot(new Date(), { seconds: 60 })))
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('finalizeHodlInvoice', jsonb_build_object('hash', ${dbInvoice.hash}), 21, true, ${expiresAt})`
}
}, { models, lnd, boss })
}
export async function settleActionError ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('settleActionError', {
invoiceId,
// any of these states can transition to FAILED
fromState: ['PENDING', 'PENDING_HELD', 'HELD'],
toState: 'FAILED',
toData: invoice => {
if (!invoice.is_canceled) {
throw new Error('invoice is not cancelled')
}
return {
cancelled: true
}
},
onTransition: async ({ dbInvoice, tx }) => {
await paidActions[dbInvoice.actionType].onFail?.({ invoice: dbInvoice }, { models, tx, lnd })
}
}, { models, lnd, boss })
}

View File

@ -96,7 +96,6 @@ async function _indexItem (item, { models, updatedAt }) {
console.log('version conflict ignoring', item.id) console.log('version conflict ignoring', item.id)
return return
} }
console.log(e)
throw e throw e
} }
} }
@ -145,7 +144,6 @@ export async function indexAllItems ({ apollo, models }) {
items.forEach(i => _indexItem(i, { models })) items.forEach(i => _indexItem(i, { models }))
} catch (e) { } catch (e) {
// ignore errors // ignore errors
console.log(e)
} }
} while (cursor) } while (cursor)
} }

View File

@ -13,12 +13,19 @@ export async function computeStreaks ({ models }) {
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent ((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
FROM "ItemAct" FROM "ItemAct"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
GROUP BY "userId") GROUP BY "userId")
UNION ALL UNION ALL
(SELECT "userId", sats as sats_spent (SELECT "userId", sats as sats_spent
FROM "Donation" FROM "Donation"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date
)) spending )
UNION ALL
(SELECT "userId", floor(sum("SubAct".msats)/1000) as sats_spent
FROM "SubAct"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date
AND "type" = 'BILLING'
GROUP BY "userId")) spending
GROUP BY "userId" GROUP BY "userId"
HAVING sum(sats_spent) >= 100 HAVING sum(sats_spent) >= 100
), existing_streaks (id, started_at) AS ( ), existing_streaks (id, started_at) AS (
@ -84,13 +91,22 @@ export async function checkStreak ({ data: { id }, models }) {
FROM "ItemAct" FROM "ItemAct"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date
AND "userId" = ${Number(id)} AND "userId" = ${Number(id)}
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
GROUP BY "userId") GROUP BY "userId")
UNION ALL UNION ALL
(SELECT "userId", sats as sats_spent (SELECT "userId", sats as sats_spent
FROM "Donation" FROM "Donation"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date
AND "userId" = ${Number(id)} AND "userId" = ${Number(id)}
)) spending )
UNION ALL
(SELECT "userId", floor(sum("SubAct".msats)/1000) as sats_spent
FROM "SubAct"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date
AND "userId" = ${Number(id)}
AND "type" = 'BILLING'
GROUP BY "userId")
) spending
GROUP BY "userId" GROUP BY "userId"
HAVING sum(sats_spent) >= ${STREAK_THRESHOLD} HAVING sum(sats_spent) >= ${STREAK_THRESHOLD}
), user_start_streak AS ( ), user_start_streak AS (

View File

@ -1,5 +1,6 @@
import lnd from '@/api/lnd'
import performPaidAction from '@/api/paidAction'
import serialize from '@/api/resolvers/serial' import serialize from '@/api/resolvers/serial'
import { paySubQueries } from '@/api/resolvers/sub'
import { nextBillingWithGrace } from '@/lib/territory' import { nextBillingWithGrace } from '@/lib/territory'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
@ -33,8 +34,11 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
} }
try { try {
const queries = paySubQueries(sub, models) const { result } = await performPaidAction('TERRITORY_BILLING',
await serialize(queries, { models }) { name: subName }, { models, me: { id: sub.userId }, lnd, forceFeeCredits: true })
if (!result) {
throw new Error('not enough fee credits to auto-renew territory')
}
} catch (e) { } catch (e) {
console.error(e) console.error(e)
await territoryStatusUpdate() await territoryStatusUpdate()
@ -57,6 +61,7 @@ export async function territoryRevenue ({ models }) {
WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() AT TIME ZONE 'America/Chicago' - interval '1 day')) WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() AT TIME ZONE 'America/Chicago' - interval '1 day'))
AND "ItemAct".act <> 'TIP' AND "ItemAct".act <> 'TIP'
AND "Sub".status <> 'STOPPED' AND "Sub".status <> 'STOPPED'
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
) subquery ) subquery
GROUP BY "subName", "userId" GROUP BY "subName", "userId"
), ),

View File

@ -128,6 +128,7 @@ async function getGraph (models) {
JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS') JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS')
AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId" AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId"
JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${USER_ID.anon} JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${USER_ID.anon}
WHERE "ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'
GROUP BY user_id, name, item_id, user_at, against GROUP BY user_id, name, item_id, user_at, against
HAVING CASE WHEN HAVING CASE WHEN
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN} "ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}

View File

@ -9,6 +9,7 @@ import { datePivot, sleep } from '@/lib/time.js'
import retry from 'async-retry' import retry from 'async-retry'
import { addWalletLog } from '@/api/resolvers/wallet' import { addWalletLog } from '@/api/resolvers/wallet'
import { msatsToSats, numWithUnits } from '@/lib/format' import { msatsToSats, numWithUnits } from '@/lib/format'
import { holdAction, settleAction, settleActionError } from './paidAction'
export async function subscribeToWallet (args) { export async function subscribeToWallet (args) {
await subscribeToDeposits(args) await subscribeToDeposits(args)
@ -77,7 +78,7 @@ async function subscribeToDeposits (args) {
return sub return sub
}) })
// check pending deposits as a redundancy in case we failed to record // check pending deposits as a redundancy in case we failed to rehcord
// an invoice_updated event // an invoice_updated event
await checkPendingDeposits(args) await checkPendingDeposits(args)
} }
@ -107,7 +108,7 @@ function subscribeToHodlInvoice (args) {
}) })
} }
async function checkInvoice ({ data: { hash }, boss, models, lnd }) { export async function checkInvoice ({ data: { hash }, boss, models, lnd }) {
const inv = await getInvoice({ id: hash, lnd }) const inv = await getInvoice({ id: hash, lnd })
// invoice could be created by LND but wasn't inserted into the database yet // invoice could be created by LND but wasn't inserted into the database yet
@ -118,12 +119,11 @@ async function checkInvoice ({ data: { hash }, boss, models, lnd }) {
return return
} }
if (dbInv.actionType) {
// this is an action invoice, we don't know how to handle yet
return
}
if (inv.is_confirmed) { if (inv.is_confirmed) {
if (dbInv.actionType) {
return await settleAction({ data: { invoiceId: dbInv.id }, models, lnd, boss })
}
// NOTE: confirm invoice prevents double confirmations (idempotent) // NOTE: confirm invoice prevents double confirmations (idempotent)
// ALSO: is_confirmed and is_held are mutually exclusive // ALSO: is_confirmed and is_held are mutually exclusive
// that is, a hold invoice will first be is_held but not is_confirmed // that is, a hold invoice will first be is_held but not is_confirmed
@ -143,6 +143,9 @@ async function checkInvoice ({ data: { hash }, boss, models, lnd }) {
} }
if (inv.is_held) { if (inv.is_held) {
if (dbInv.actionType) {
return await holdAction({ data: { invoiceId: dbInv.id }, models, lnd, boss })
}
// First query makes sure that after payment, JIT invoices are settled // First query makes sure that after payment, JIT invoices are settled
// within 60 seconds or they will be canceled to minimize risk of // within 60 seconds or they will be canceled to minimize risk of
// force closures or wallets banning us. // force closures or wallets banning us.
@ -166,6 +169,10 @@ async function checkInvoice ({ data: { hash }, boss, models, lnd }) {
} }
if (inv.is_canceled) { if (inv.is_canceled) {
if (dbInv.actionType) {
return await settleActionError({ data: { invoiceId: dbInv.id }, models, lnd, boss })
}
return await serialize( return await serialize(
models.invoice.update({ models.invoice.update({
where: { where: {