Compare commits
170 Commits
ef700e188a
...
5d03e08514
Author | SHA1 | Date | |
---|---|---|---|
|
5d03e08514 | ||
|
6a5713034b | ||
|
c8d91bf42d | ||
|
08a5ce1a28 | ||
|
4df0b460c3 | ||
|
587bfa34be | ||
|
3933a4f460 | ||
|
667cde6042 | ||
|
6432ea7b44 | ||
|
fb2b34ce67 | ||
|
9587ff9a52 | ||
|
538f1e21d6 | ||
|
e25a3dbec0 | ||
|
128f1f93b8 | ||
|
b777fdcddc | ||
|
3a748b8d38 | ||
|
98efe763a0 | ||
|
c6581b2cb1 | ||
|
bbcfc2fada | ||
|
5b2e835722 | ||
|
259ebef971 | ||
|
7851366cd5 | ||
|
cba76444dd | ||
|
f01ce79afa | ||
|
03ca84629b | ||
|
7749c14d3b | ||
|
ee1574cf45 | ||
|
6ac675429c | ||
|
dd4806c1a3 | ||
|
e045c46811 | ||
|
c767e106a0 | ||
|
e1d6632445 | ||
|
da65191cd8 | ||
|
3f9d509a52 | ||
|
bc2cb29c41 | ||
|
cb6b85345c | ||
|
35cf792ff8 | ||
|
b31a8dbf2c | ||
|
2e90f02997 | ||
|
0aea695d8a | ||
|
729aab12eb | ||
|
9ac31095c8 | ||
|
07042c57ca | ||
|
28c4fa160c | ||
|
501ac9f220 | ||
|
6c6d2dab18 | ||
|
64eb22cc5e | ||
|
ce45574bce | ||
|
c6554d3ca7 | ||
|
6cf16d3da7 | ||
|
94d9d9513c | ||
|
f05b6fab84 | ||
|
e0f91ace41 | ||
|
0312012089 | ||
|
02472bb81f | ||
|
6e6af40eb9 | ||
|
05c0f8a66e | ||
|
80756f23a4 | ||
|
24bdf0a099 | ||
|
d9205b6d30 | ||
|
7402885998 | ||
|
1a60f13d72 | ||
|
920478a72c | ||
|
9af8e63355 | ||
|
8a36bffb85 | ||
|
8ea4d0c8a7 | ||
|
2051dd0e88 | ||
|
5d678ced23 | ||
|
459478036f | ||
|
a69bca0f05 | ||
|
85cfda330b | ||
|
85464f93b9 | ||
|
dddbb53792 | ||
|
ebe741dc92 | ||
|
6bee659f2f | ||
|
bd0e4d906c | ||
|
7528e5c2b6 | ||
|
1ce09051b1 | ||
|
8dac53d7d5 | ||
|
cd074a47b7 | ||
|
12bedae01a | ||
|
b569c8faa0 | ||
|
ba00c3d9fa | ||
|
00f78daadc | ||
|
0a0085fe82 | ||
|
48ead97615 | ||
|
6463e6eec8 | ||
|
0ebe097a70 | ||
|
850c534c91 | ||
|
83fd39b035 | ||
|
9bbf2056e9 | ||
|
8acf74c787 | ||
|
55928ac252 | ||
|
c270805649 | ||
|
eb2f4b980f | ||
|
b96757b366 | ||
|
39d8928772 | ||
|
da6d262e0a | ||
|
d20e258649 | ||
|
d60e26bfdf | ||
|
9509833b88 | ||
|
645ff78365 | ||
|
c18263dc73 | ||
|
d8e82ddea5 | ||
|
e091377d94 | ||
|
5b561e22a9 | ||
|
4bf9954c4e | ||
|
3b0605a691 | ||
|
1f98a1a891 | ||
|
377ac04c85 | ||
|
9228328d3b | ||
|
2aa0c9bc99 | ||
|
d7c81cfa9f | ||
|
4a16cc17aa | ||
|
4082a45618 | ||
|
ae0335d537 | ||
|
91978171ed | ||
|
dae69ec4b3 | ||
|
eda7fd6b46 | ||
|
fd08356d37 | ||
|
61be80446d | ||
|
6059e8f691 | ||
|
1bae891594 | ||
|
276e734a7a | ||
|
7b6602e386 | ||
|
8e2dd45e23 | ||
|
7639390a16 | ||
|
29646eb956 | ||
|
dd47f2c02b | ||
|
a5ea53dc39 | ||
|
399c62a7e3 | ||
|
034cb4e8b2 | ||
|
b8b0a4f985 | ||
|
0957cb5b83 | ||
|
71c753810c | ||
|
0de82db78a | ||
|
1a2be99027 | ||
|
6ac8785c51 | ||
|
a1b343ac89 | ||
|
5f047cbfc9 | ||
|
3710840167 | ||
|
3bada4b5da | ||
|
fc781047d5 | ||
|
9213e3ad1a | ||
|
bcdbf9cede | ||
|
79f0df17b2 | ||
|
7c294478fb | ||
|
1a3785a865 | ||
|
2dd96f4b83 | ||
|
9145f290dc | ||
|
63e60fe2bc | ||
|
0aa5ba4955 | ||
|
6e8d7ef1b8 | ||
|
e57c930f0c | ||
|
6e1d67b3c0 | ||
|
1e9d1ce66c | ||
|
ca11ac9fb8 | ||
|
30e29f709d | ||
|
fb52d5314d | ||
|
9e44baa7f5 | ||
|
658fe73920 | ||
|
4fc832c7ed | ||
|
1e3d37d6b0 | ||
|
a95402e3be | ||
|
ddaec36617 | ||
|
3f6581f119 | ||
|
923b21610f | ||
|
87e198cc04 | ||
|
a55e865222 | ||
|
4fe920d12b |
@ -1,3 +1,6 @@
|
|||||||
|
PRISMA_SLOW_LOGS_MS=
|
||||||
|
GRAPHQL_SLOW_LOGS_MS=
|
||||||
|
|
||||||
############################################################################
|
############################################################################
|
||||||
# OPTIONAL SECRETS #
|
# OPTIONAL SECRETS #
|
||||||
# put these in .env.local, and don't commit them to git #
|
# put these in .env.local, and don't commit them to git #
|
||||||
@ -89,6 +92,9 @@ OPENSEARCH_MODEL_ID=
|
|||||||
|
|
||||||
# prisma db url
|
# prisma db url
|
||||||
DATABASE_URL="postgresql://sn:password@db:5432/stackernews?schema=public"
|
DATABASE_URL="postgresql://sn:password@db:5432/stackernews?schema=public"
|
||||||
|
DB_APP_CONNECTION_LIMIT=2
|
||||||
|
DB_WORKER_CONNECTION_LIMIT=2
|
||||||
|
DB_TRANSACTION_TIMEOUT=5000
|
||||||
|
|
||||||
# polling intervals
|
# polling intervals
|
||||||
NEXT_PUBLIC_FAST_POLL_INTERVAL=1000
|
NEXT_PUBLIC_FAST_POLL_INTERVAL=1000
|
||||||
|
@ -17,3 +17,8 @@ NEXT_PUBLIC_LONG_POLL_INTERVAL=60000
|
|||||||
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
|
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
|
||||||
NEXT_PUBLIC_URL=https://stacker.news
|
NEXT_PUBLIC_URL=https://stacker.news
|
||||||
TOR_PROXY=http://127.0.0.1:7050/
|
TOR_PROXY=http://127.0.0.1:7050/
|
||||||
|
PRISMA_SLOW_LOGS_MS=50
|
||||||
|
GRAPHQL_SLOW_LOGS_MS=50
|
||||||
|
DB_APP_CONNECTION_LIMIT=4
|
||||||
|
DB_WORKER_CONNECTION_LIMIT=2
|
||||||
|
DB_TRANSACTION_TIMEOUT=10000
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,7 +20,6 @@ node_modules/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
/*.sql
|
/*.sql
|
||||||
lnbits/
|
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
@ -104,7 +104,7 @@ COMMANDS
|
|||||||
|
|
||||||
#### Running specific services
|
#### Running specific services
|
||||||
|
|
||||||
By default all services will be run. If you want to exclude specific services from running, set `COMPOSE_PROFILES` to use one or more of `minimal|images|search|payments|email|capture`. To only run mininal services without images, search, or payments:
|
By default all services will be run. If you want to exclude specific services from running, set `COMPOSE_PROFILES` to use one or more of `minimal|images|search|payments|wallets|email|capture`. To only run mininal services without images, search, email, wallets, or payments:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ COMPOSE_PROFILES=minimal ./sndev start
|
$ COMPOSE_PROFILES=minimal ./sndev start
|
||||||
|
@ -1,18 +1,12 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
import createPrisma from '@/lib/create-prisma'
|
||||||
|
|
||||||
const prisma = global.prisma || (() => {
|
const prisma = global.prisma || (() => {
|
||||||
console.log('initing prisma')
|
console.log('initing prisma')
|
||||||
const prisma = new PrismaClient({
|
return createPrisma({
|
||||||
log: [{ level: 'query', emit: 'event' }, 'warn', 'error']
|
connectionParams: {
|
||||||
})
|
connection_limit: process.env.DB_APP_CONNECTION_LIMIT
|
||||||
prisma.$on('query', (e) => {
|
|
||||||
if (e.duration > 50) {
|
|
||||||
console.log('Query: ' + e.query)
|
|
||||||
console.log('Params: ' + e.params)
|
|
||||||
console.log('Duration: ' + e.duration + 'ms')
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return prisma
|
|
||||||
})()
|
})()
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') global.prisma = prisma
|
if (process.env.NODE_ENV === 'development') global.prisma = prisma
|
||||||
|
204
api/paidAction/README.md
Normal file
204
api/paidAction/README.md
Normal 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 performing the action and only storing the action's arguments. After the client pays the invoice, the server performs the action with original arguments. Pessimistic actions require 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 and the action's `perform` is run and the invoice is settled
|
||||||
|
- `HELD` -> `PAID`: when the action's `onPaid` is called
|
||||||
|
- `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:
|
||||||
|
- `me`: the user performing the action (undefined 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.
|
26
api/paidAction/buyCredits.js
Normal file
26
api/paidAction/buyCredits.js
Normal 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
25
api/paidAction/donate.js
Normal 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
79
api/paidAction/downZap.js
Normal 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}`
|
||||||
|
}
|
282
api/paidAction/index.js
Normal file
282
api/paidAction/index.js
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
import { createHodlInvoice, createInvoice } 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 * 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, forceFeeCredits } = context
|
||||||
|
const paidAction = paidActions[actionType]
|
||||||
|
|
||||||
|
console.group('performPaidAction', actionType, args)
|
||||||
|
|
||||||
|
if (!paidAction) {
|
||||||
|
throw new Error(`Invalid action type ${actionType}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.me = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined
|
||||||
|
context.cost = await paidAction.getCost(args, context)
|
||||||
|
|
||||||
|
if (!me) {
|
||||||
|
if (!paidAction.anonable) {
|
||||||
|
throw new Error('You must be logged in to perform this action')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('we are anon so can only perform pessimistic action')
|
||||||
|
return await performPessimisticAction(actionType, args, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRich = context.cost <= context.me.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 with fee credits, but not because of insufficient funds, bail
|
||||||
|
if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 we fail to do the action with fee credits, we should fall back to optimistic
|
||||||
|
if (paidAction.supportsOptimism) {
|
||||||
|
console.log('performing optimistic action')
|
||||||
|
return await performOptimisticAction(actionType, args, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('action does not support optimism and fee credits failed, performing pessimistic action')
|
||||||
|
return await performPessimisticAction(actionType, args, context)
|
||||||
|
} 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]
|
||||||
|
|
||||||
|
context.optimistic = true
|
||||||
|
context.lndInvoice = await createLndInvoice(actionType, args, context)
|
||||||
|
|
||||||
|
return await models.$transaction(async tx => {
|
||||||
|
context.tx = tx
|
||||||
|
|
||||||
|
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 action = paidActions[actionType]
|
||||||
|
|
||||||
|
if (!action.supportsPessimism) {
|
||||||
|
throw new Error(`This action ${actionType} does not support pessimistic invoicing`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// just create the invoice and complete action when it's paid
|
||||||
|
context.lndInvoice = await createLndInvoice(actionType, args, context)
|
||||||
|
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.optimistic = true
|
||||||
|
context.me = await models.user.findUnique({ where: { id: me.id } })
|
||||||
|
|
||||||
|
const { msatsRequested } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
|
||||||
|
context.cost = BigInt(msatsRequested)
|
||||||
|
context.lndInvoice = await createLndInvoice(actionType, args, context)
|
||||||
|
|
||||||
|
return await models.$transaction(async tx => {
|
||||||
|
context.tx = tx
|
||||||
|
|
||||||
|
// update the old invoice to RETRYING, so that it's not confused with FAILED
|
||||||
|
const { actionId } = await tx.invoice.update({
|
||||||
|
where: {
|
||||||
|
id: invoiceId,
|
||||||
|
actionState: 'FAILED'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
actionState: 'RETRYING'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 }
|
||||||
|
|
||||||
|
// we seperate the invoice creation into two functions because
|
||||||
|
// because if lnd is slow, it'll timeout the interactive tx
|
||||||
|
async function createLndInvoice (actionType, args, context) {
|
||||||
|
const { me, lnd, cost, optimistic } = context
|
||||||
|
const action = paidActions[actionType]
|
||||||
|
const [createLNDInvoice, expirePivot] = optimistic
|
||||||
|
? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE]
|
||||||
|
: [createHodlInvoice, PESSIMISTIC_INVOICE_EXPIRE]
|
||||||
|
|
||||||
|
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)
|
||||||
|
return await createLNDInvoice({
|
||||||
|
description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context),
|
||||||
|
lnd,
|
||||||
|
mtokens: String(cost),
|
||||||
|
expires_at: expiresAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDbInvoice (actionType, args, context) {
|
||||||
|
const { me, models, tx, lndInvoice, cost, optimistic, actionId } = context
|
||||||
|
const db = tx ?? models
|
||||||
|
const [expirePivot, actionState] = optimistic
|
||||||
|
? [OPTIMISTIC_INVOICE_EXPIRE, 'PENDING']
|
||||||
|
: [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 invoice = await db.invoice.create({
|
||||||
|
data: {
|
||||||
|
hash: lndInvoice.id,
|
||||||
|
msatsRequested: cost,
|
||||||
|
preimage: optimistic ? undefined : lndInvoice.secret,
|
||||||
|
bolt11: lndInvoice.request,
|
||||||
|
userId: me?.id ?? USER_ID.anon,
|
||||||
|
actionType,
|
||||||
|
actionState,
|
||||||
|
actionArgs: args,
|
||||||
|
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', ${lndInvoice.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
|
||||||
|
}
|
242
api/paidAction/itemCreate.js
Normal file
242
api/paidAction/itemCreate.js
Normal 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, me }) {
|
||||||
|
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, ${me?.id ?? USER_ID.anon}::INTEGER,
|
||||||
|
${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
|
||||||
|
* ${me ? 1 : 100}::INTEGER
|
||||||
|
+ (SELECT "nUnpaid" * "imageFeeMsats"
|
||||||
|
FROM image_fees_info(${me?.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 && !!me && cost > me?.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')`
|
||||||
|
|
||||||
|
if (item.parentId) {
|
||||||
|
// denormalize ncomments, lastCommentAt, and "weightedComments" for ancestors, and insert into reply table
|
||||||
|
await tx.$executeRaw`
|
||||||
|
WITH comment AS (
|
||||||
|
SELECT "Item".*, users.trust
|
||||||
|
FROM "Item"
|
||||||
|
JOIN users ON "Item"."userId" = users.id
|
||||||
|
WHERE "Item".id = ${item.id}::INTEGER
|
||||||
|
), ancestors AS (
|
||||||
|
UPDATE "Item"
|
||||||
|
SET ncomments = "Item".ncomments + 1,
|
||||||
|
"lastCommentAt" = now(),
|
||||||
|
"weightedComments" = "Item"."weightedComments" +
|
||||||
|
CASE WHEN comment."userId" = "Item"."userId" THEN 0 ELSE comment.trust 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, models }).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { userId } of item.mentions) {
|
||||||
|
notifyMention({ models, item, userId }).catch(console.error)
|
||||||
|
}
|
||||||
|
for (const { refereeItem } of item.itemReferrers) {
|
||||||
|
notifyItemMention({ models, referrerItem: item, refereeItem }).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'}`
|
||||||
|
}
|
153
api/paidAction/itemUpdate.js
Normal file
153
api/paidAction/itemUpdate.js
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
// compare timestamps to only notify if mention or item referral was just created to avoid duplicates on edits
|
||||||
|
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'}`
|
||||||
|
}
|
89
api/paidAction/lib/item.js
Normal file
89
api/paidAction/lib/item.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
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 },
|
||||||
|
userId: { not: me?.id || USER_ID.anon }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
65
api/paidAction/pollVote.js
Normal file
65
api/paidAction/pollVote.js
Normal 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}`
|
||||||
|
}
|
69
api/paidAction/territoryBilling.js
Normal file
69
api/paidAction/territoryBilling.js
Normal 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}`
|
||||||
|
}
|
44
api/paidAction/territoryCreate.js
Normal file
44
api/paidAction/territoryCreate.js
Normal 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}`
|
||||||
|
}
|
61
api/paidAction/territoryUnarchive.js
Normal file
61
api/paidAction/territoryUnarchive.js
Normal 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}`
|
||||||
|
}
|
79
api/paidAction/territoryUpdate.js
Normal file
79
api/paidAction/territoryUpdate.js
Normal 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}`
|
||||||
|
}
|
155
api/paidAction/zap.js
Normal file
155
api/paidAction/zap.js
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
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(10) // 10% fee
|
||||||
|
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,
|
||||||
|
"stackedMsats" = users."stackedMsats" + forwardees.msats
|
||||||
|
FROM forwardees
|
||||||
|
WHERE users.id = forwardees."userId"
|
||||||
|
)
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
msats = msats + ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT,
|
||||||
|
"stackedMsats" = "stackedMsats" + ${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
|
||||||
|
const [item] = await tx.$queryRaw`
|
||||||
|
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
|
||||||
|
RETURNING "Item".*`
|
||||||
|
|
||||||
|
// 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`
|
||||||
|
|
||||||
|
notifyZapped({ models, item }).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}`
|
||||||
|
}
|
@ -1,26 +1,5 @@
|
|||||||
import { timeUnitForRange, whenRange } from '@/lib/time'
|
import { timeUnitForRange, whenRange } from '@/lib/time'
|
||||||
|
|
||||||
export function withClause (range) {
|
|
||||||
const unit = timeUnitForRange(range)
|
|
||||||
|
|
||||||
return `
|
|
||||||
WITH range_values AS (
|
|
||||||
SELECT date_trunc('${unit}', $1) as minval,
|
|
||||||
date_trunc('${unit}', $2) as maxval
|
|
||||||
),
|
|
||||||
times AS (
|
|
||||||
SELECT generate_series(minval, maxval, interval '1 ${unit}') as time
|
|
||||||
FROM range_values
|
|
||||||
)
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function intervalClause (range, table) {
|
|
||||||
const unit = timeUnitForRange(range)
|
|
||||||
|
|
||||||
return `date_trunc('${unit}', "${table}".created_at) >= date_trunc('${unit}', $1) AND date_trunc('${unit}', "${table}".created_at) <= date_trunc('${unit}', $2) `
|
|
||||||
}
|
|
||||||
|
|
||||||
export function viewIntervalClause (range, view) {
|
export function viewIntervalClause (range, view) {
|
||||||
const unit = timeUnitForRange(range)
|
const unit = timeUnitForRange(range)
|
||||||
return `"${view}".t >= date_trunc('${unit}', timezone('America/Chicago', $1)) AND date_trunc('${unit}', "${view}".t) <= date_trunc('${unit}', timezone('America/Chicago', $2)) `
|
return `"${view}".t >= date_trunc('${unit}', timezone('America/Chicago', $1)) AND date_trunc('${unit}', "${view}".t) <= date_trunc('${unit}', timezone('America/Chicago', $2)) `
|
||||||
@ -42,31 +21,11 @@ export function viewGroup (range, view) {
|
|||||||
${view}(
|
${view}(
|
||||||
date_trunc('hour', timezone('America/Chicago', now())),
|
date_trunc('hour', timezone('America/Chicago', now())),
|
||||||
date_trunc('hour', timezone('America/Chicago', now())), '1 hour'::INTERVAL, 'hour')
|
date_trunc('hour', timezone('America/Chicago', now())), '1 hour'::INTERVAL, 'hour')
|
||||||
WHERE "${view}".t >= date_trunc('${unit}', timezone('America/Chicago', $1))
|
WHERE "${view}".t >= date_trunc('hour', timezone('America/Chicago', $1))
|
||||||
AND "${view}".t <= date_trunc('${unit}', timezone('America/Chicago', $2)))
|
AND "${view}".t <= date_trunc('hour', timezone('America/Chicago', $2)))
|
||||||
) 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 }) => {
|
||||||
|
@ -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 }) {
|
||||||
|
@ -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]
|
||||||
|
@ -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,41 +735,37 @@ 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 })
|
||||||
},
|
},
|
||||||
upsertLink: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
|
upsertLink: async (parent, { id, ...item }, { me, models, lnd }) => {
|
||||||
await ssValidate(linkSchema, item, { models, me })
|
await ssValidate(linkSchema, item, { models, me })
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
|
return await updateItem(parent, { id, ...item }, { me, models, lnd })
|
||||||
} else {
|
} else {
|
||||||
return await createItem(parent, item, { me, models, lnd, hash, hmac })
|
return await createItem(parent, item, { me, models, lnd })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
upsertDiscussion: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
|
upsertDiscussion: async (parent, { id, ...item }, { me, models, lnd }) => {
|
||||||
await ssValidate(discussionSchema, item, { models, me })
|
await ssValidate(discussionSchema, item, { models, me })
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
|
return await updateItem(parent, { id, ...item }, { me, models, lnd })
|
||||||
} else {
|
} else {
|
||||||
return await createItem(parent, item, { me, models, lnd, hash, hmac })
|
return await createItem(parent, item, { me, models, lnd })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
upsertBounty: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
|
upsertBounty: async (parent, { id, ...item }, { me, models, lnd }) => {
|
||||||
await ssValidate(bountySchema, item, { models, me })
|
await ssValidate(bountySchema, item, { models, me })
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
|
return await updateItem(parent, { id, ...item }, { me, models, lnd })
|
||||||
} else {
|
} else {
|
||||||
return await createItem(parent, item, { me, models, lnd, hash, hmac })
|
return await createItem(parent, item, { me, models, lnd })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
upsertPoll: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
|
upsertPoll: async (parent, { id, ...item }, { me, models, lnd }) => {
|
||||||
const numExistingChoices = id
|
const numExistingChoices = id
|
||||||
? await models.pollOption.count({
|
? await models.pollOption.count({
|
||||||
where: {
|
where: {
|
||||||
@ -770,13 +777,13 @@ export default {
|
|||||||
await ssValidate(pollSchema, item, { models, me, numExistingChoices })
|
await ssValidate(pollSchema, item, { models, me, numExistingChoices })
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
|
return await updateItem(parent, { id, ...item }, { me, models, lnd })
|
||||||
} else {
|
} else {
|
||||||
item.pollCost = item.pollCost || POLL_COST
|
item.pollCost = item.pollCost || POLL_COST
|
||||||
return await createItem(parent, item, { me, models, lnd, hash, hmac })
|
return await createItem(parent, item, { me, models, lnd })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
upsertJob: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
|
upsertJob: async (parent, { id, ...item }, { me, models, lnd }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } })
|
throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } })
|
||||||
}
|
}
|
||||||
@ -790,19 +797,18 @@ 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 })
|
||||||
} else {
|
} else {
|
||||||
return await createItem(parent, item, { me, models, lnd, hash, hmac })
|
return await createItem(parent, item, { me, models, lnd })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
upsertComment: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
|
upsertComment: async (parent, { id, ...item }, { me, models, lnd }) => {
|
||||||
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 })
|
||||||
} else {
|
} else {
|
||||||
item = await createItem(parent, item, { me, models, lnd, hash, hmac })
|
item = await createItem(parent, item, { me, models, lnd })
|
||||||
notifyItemParents({ item, me, models })
|
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -818,19 +824,14 @@ export default {
|
|||||||
|
|
||||||
return { id, noteId }
|
return { id, noteId }
|
||||||
},
|
},
|
||||||
pollVote: async (parent, { id, hash, hmac }, { me, models, lnd }) => {
|
pollVote: async (parent, { id }, { me, models, lnd }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
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 })
|
||||||
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 }, { me, models, lnd, headers }) => {
|
||||||
assertApiKeyNotPermitted({ me })
|
assertApiKeyNotPermitted({ me })
|
||||||
await ssValidate(actSchema, { sats, act })
|
await ssValidate(actSchema, { sats, act })
|
||||||
await assertGofacYourself({ models, headers })
|
await assertGofacYourself({ models, headers })
|
||||||
@ -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 })
|
||||||
models.$queryRaw`
|
} else if (act === 'DONT_LIKE_THIS') {
|
||||||
SELECT
|
return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd })
|
||||||
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 poll = {}
|
||||||
|
if (me) {
|
||||||
const meVoted = await models.pollBlindVote.findFirst({
|
const meVoted = await models.pollBlindVote.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: me?.id,
|
userId: me.id,
|
||||||
itemId: item.id
|
itemId: item.id
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const poll = {}
|
|
||||||
poll.options = options
|
|
||||||
poll.meVoted = !!meVoted
|
poll.meVoted = !!meVoted
|
||||||
|
poll.meInvoiceId = meVoted?.invoiceId
|
||||||
|
poll.meInvoiceActionState = meVoted?.invoiceActionState
|
||||||
|
} else {
|
||||||
|
poll.meVoted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
poll.options = options
|
||||||
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'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1147,7 +1155,32 @@ export default {
|
|||||||
if (item.root) {
|
if (item.root) {
|
||||||
return item.root
|
return item.root
|
||||||
}
|
}
|
||||||
return await getItem(item, { id: item.rootId }, { me, models })
|
|
||||||
|
// we can't use getItem because activeOrMine will prevent root from being fetched
|
||||||
|
const [root] = await itemQueryWithMeta({
|
||||||
|
me,
|
||||||
|
models,
|
||||||
|
query: `
|
||||||
|
${SELECT}
|
||||||
|
FROM "Item"
|
||||||
|
${whereClause(
|
||||||
|
'"Item".id = $1',
|
||||||
|
`("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID'${me ? ` OR "Item"."userId" = ${me.id}` : ''})`
|
||||||
|
)}`
|
||||||
|
}, Number(item.rootId))
|
||||||
|
|
||||||
|
return root
|
||||||
|
},
|
||||||
|
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) {
|
||||||
@ -1168,7 +1201,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 +1215,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 }) => {
|
||||||
const refPattern = new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/\\d+.*`, '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 +1270,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 +1280,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) {
|
|
||||||
item.boost = item.boost - old.boost
|
|
||||||
} else {
|
} else {
|
||||||
delete item.boost
|
item = { subName, userId: me.id, ...item }
|
||||||
}
|
item.forwardUsers = await getForwardUsers(models, forward)
|
||||||
}
|
}
|
||||||
|
item.uploadIds = uploadIdsFromText(item.text, { models })
|
||||||
|
|
||||||
item = { subName, userId: old.userId, ...item }
|
const resultItem = await performPaidAction('ITEM_UPDATE', item, { models, me, lnd })
|
||||||
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 }) => {
|
||||||
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 })
|
||||||
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 +1345,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`
|
||||||
|
@ -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})`
|
||||||
@ -282,6 +284,7 @@ export default {
|
|||||||
FROM "Earn"
|
FROM "Earn"
|
||||||
WHERE "userId" = $1
|
WHERE "userId" = $1
|
||||||
AND created_at < $2
|
AND created_at < $2
|
||||||
|
AND (type IS NULL OR type NOT IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL'))
|
||||||
GROUP BY "userId", created_at
|
GROUP BY "userId", created_at
|
||||||
ORDER BY "sortTime" DESC
|
ORDER BY "sortTime" DESC
|
||||||
LIMIT ${LIMIT})`
|
LIMIT ${LIMIT})`
|
||||||
@ -297,6 +300,17 @@ export default {
|
|||||||
ORDER BY "sortTime" DESC
|
ORDER BY "sortTime" DESC
|
||||||
LIMIT ${LIMIT})`
|
LIMIT ${LIMIT})`
|
||||||
)
|
)
|
||||||
|
queries.push(
|
||||||
|
`(SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats",
|
||||||
|
'ReferralReward' AS type
|
||||||
|
FROM "Earn"
|
||||||
|
WHERE "userId" = $1
|
||||||
|
AND created_at < $2
|
||||||
|
AND type IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL')
|
||||||
|
GROUP BY "userId", created_at
|
||||||
|
ORDER BY "sortTime" DESC
|
||||||
|
LIMIT ${LIMIT})`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meFull.noteCowboyHat) {
|
if (meFull.noteCowboyHat) {
|
||||||
@ -330,6 +344,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"
|
||||||
@ -469,6 +499,22 @@ export default {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
ReferralReward: {
|
||||||
|
sources: async (n, args, { me, models }) => {
|
||||||
|
const [sources] = await models.$queryRawUnsafe(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(FLOOR(sum(msats) FILTER(WHERE type = 'FOREVER_REFERRAL') / 1000), 0) AS forever,
|
||||||
|
COALESCE(FLOOR(sum(msats) FILTER(WHERE type = 'ONE_DAY_REFERRAL') / 1000), 0) AS "oneDay"
|
||||||
|
FROM "Earn"
|
||||||
|
WHERE "userId" = $1 AND created_at = $2
|
||||||
|
`, Number(me.id), new Date(n.sortTime))
|
||||||
|
if (sources.forever + sources.oneDay > 0) {
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
Mention: {
|
Mention: {
|
||||||
mention: async (n, args, { models }) => true,
|
mention: async (n, args, { models }) => true,
|
||||||
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
|
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
|
||||||
@ -479,6 +525,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 })
|
||||||
},
|
},
|
||||||
|
64
api/resolvers/paidAction.js
Normal file
64
api/resolvers/paidAction.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { retryPaidAction } from '../paidAction'
|
||||||
|
import { USER_ID } from '@/lib/constants'
|
||||||
|
|
||||||
|
function paidActionType (actionType) {
|
||||||
|
switch (actionType) {
|
||||||
|
case 'ITEM_CREATE':
|
||||||
|
case 'ITEM_UPDATE':
|
||||||
|
return 'ItemPaidAction'
|
||||||
|
case 'ZAP':
|
||||||
|
case 'DOWN_ZAP':
|
||||||
|
return 'ItemActPaidAction'
|
||||||
|
case 'TERRITORY_CREATE':
|
||||||
|
case 'TERRITORY_UPDATE':
|
||||||
|
case 'TERRITORY_BILLING':
|
||||||
|
case 'TERRITORY_UNARCHIVE':
|
||||||
|
return 'SubPaidAction'
|
||||||
|
case 'DONATE':
|
||||||
|
return 'DonatePaidAction'
|
||||||
|
case 'POLL_VOTE':
|
||||||
|
return 'PollVotePaidAction'
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown action type')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
Query: {
|
||||||
|
paidAction: async (parent, { invoiceId }, { models, me }) => {
|
||||||
|
const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me?.id ?? USER_ID.anon } })
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error('Invoice not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: paidActionType(invoice.actionType),
|
||||||
|
invoice,
|
||||||
|
result: invoice.actionResult,
|
||||||
|
paymentMethod: invoice.preimage ? 'PESSIMISTIC' : 'OPTIMISTIC'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await retryPaidAction(invoice.actionType, { invoiceId }, { models, me, lnd })
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
type: paidActionType(invoice.actionType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PaidAction: {
|
||||||
|
__resolveType: obj => obj.type
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
import { withClause, intervalClause } from './growth'
|
|
||||||
import { timeUnitForRange, whenRange } from '@/lib/time'
|
import { timeUnitForRange, whenRange } from '@/lib/time'
|
||||||
|
import { viewGroup } from './growth'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
@ -11,46 +11,18 @@ export default {
|
|||||||
|
|
||||||
const range = whenRange(when, from, to)
|
const range = whenRange(when, from, to)
|
||||||
|
|
||||||
const [{ totalSats }] = await models.$queryRawUnsafe(`
|
return await models.$queryRawUnsafe(`
|
||||||
SELECT COALESCE(FLOOR(sum(msats) / 1000), 0) as "totalSats"
|
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time,
|
||||||
FROM "ReferralAct"
|
json_build_array(
|
||||||
WHERE ${intervalClause(range, 'ReferralAct')}
|
json_build_object('name', 'referrals', 'value', COALESCE(SUM(referrals), 0)),
|
||||||
AND "ReferralAct"."referrerId" = $3
|
json_build_object('name', 'one day referrals', 'value', COALESCE(SUM(one_day_referrals), 0)),
|
||||||
`, ...range, Number(me.id))
|
json_build_object('name', 'referral sats', 'value', FLOOR(COALESCE(SUM(msats_referrals), 0) / 1000.0)),
|
||||||
|
json_build_object('name', 'one day referral sats', 'value', FLOOR(COALESCE(SUM(msats_one_day_referrals), 0) / 1000.0))
|
||||||
const [{ totalReferrals }] = await models.$queryRawUnsafe(`
|
|
||||||
SELECT count(*)::INTEGER as "totalReferrals"
|
|
||||||
FROM users
|
|
||||||
WHERE ${intervalClause(range, 'users')}
|
|
||||||
AND "referrerId" = $3
|
|
||||||
`, ...range, Number(me.id))
|
|
||||||
|
|
||||||
const stats = await models.$queryRawUnsafe(
|
|
||||||
`${withClause(range)}
|
|
||||||
SELECT time, json_build_array(
|
|
||||||
json_build_object('name', 'referrals', 'value', count(*) FILTER (WHERE act = 'REFERREE')),
|
|
||||||
json_build_object('name', 'sats', 'value', FLOOR(COALESCE(sum(msats) FILTER (WHERE act IN ('BOOST', 'STREAM', 'FEE')), 0)))
|
|
||||||
) AS data
|
) AS data
|
||||||
FROM times
|
FROM ${viewGroup(range, 'user_stats')}
|
||||||
LEFT JOIN
|
WHERE id = ${me.id}
|
||||||
((SELECT "ReferralAct".created_at, "ReferralAct".msats / 1000.0 as msats, "ItemAct".act::text as act
|
|
||||||
FROM "ReferralAct"
|
|
||||||
JOIN "ItemAct" ON "ItemAct".id = "ReferralAct"."itemActId"
|
|
||||||
WHERE ${intervalClause(range, 'ReferralAct')}
|
|
||||||
AND "ReferralAct"."referrerId" = $3)
|
|
||||||
UNION ALL
|
|
||||||
(SELECT created_at, 0.0 as sats, 'REFERREE' as act
|
|
||||||
FROM users
|
|
||||||
WHERE ${intervalClause(range, 'users')}
|
|
||||||
AND "referrerId" = $3)) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
|
|
||||||
GROUP BY time
|
GROUP BY time
|
||||||
ORDER BY time ASC`, ...range, Number(me.id))
|
ORDER BY time ASC`, ...range)
|
||||||
|
|
||||||
return {
|
|
||||||
totalSats,
|
|
||||||
totalReferrals,
|
|
||||||
stats
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
|
||||||
@ -158,18 +157,19 @@ export default {
|
|||||||
SELECT date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago' as from,
|
SELECT date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago' as from,
|
||||||
(date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago') + interval '1 day - 1 second' as to`
|
(date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago') + interval '1 day - 1 second' as to`
|
||||||
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 100 }, { models, ...context })
|
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 100 }, { models, ...context })
|
||||||
|
},
|
||||||
|
total: async (parent, args, { models }) => {
|
||||||
|
if (!parent.total) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return parent.total
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
donateToRewards: async (parent, { sats, hash, hmac }, { me, models, lnd }) => {
|
donateToRewards: async (parent, { sats }, { me, models, lnd }) => {
|
||||||
await ssValidate(amountSchema, { amount: sats })
|
await ssValidate(amountSchema, { amount: sats })
|
||||||
|
|
||||||
await serialize(
|
return await performPaidAction('DONATE', { sats }, { me, models, lnd })
|
||||||
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: {
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
|
@ -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
|
||||||
@ -208,7 +152,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
upsertSub: async (parent, { hash, hmac, ...data }, { me, models, lnd }) => {
|
upsertSub: async (parent, { ...data }, { me, models, lnd }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
}
|
}
|
||||||
@ -216,12 +160,12 @@ export default {
|
|||||||
await ssValidate(territorySchema, data, { models, me, sub: { name: data.oldName } })
|
await ssValidate(territorySchema, data, { models, me, sub: { name: data.oldName } })
|
||||||
|
|
||||||
if (data.oldName) {
|
if (data.oldName) {
|
||||||
return await updateSub(parent, data, { me, models, lnd, hash, hmac })
|
return await updateSub(parent, data, { me, models, lnd })
|
||||||
} else {
|
} else {
|
||||||
return await createSub(parent, data, { me, models, lnd, hash, hmac })
|
return await createSub(parent, data, { me, models, lnd })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
paySub: async (parent, { name, hash, hmac }, { me, models, lnd }) => {
|
paySub: async (parent, { name }, { me, models, lnd }) => {
|
||||||
// check that they own the sub
|
// check that they own the sub
|
||||||
const sub = await models.sub.findUnique({
|
const sub = await models.sub.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@ -241,15 +185,7 @@ export default {
|
|||||||
return sub
|
return sub
|
||||||
}
|
}
|
||||||
|
|
||||||
const queries = paySubQueries(sub, models)
|
return await performPaidAction('TERRITORY_BILLING', { name }, { me, models, lnd })
|
||||||
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) {
|
||||||
@ -317,7 +253,7 @@ export default {
|
|||||||
|
|
||||||
return updatedSub
|
return updatedSub
|
||||||
},
|
},
|
||||||
unarchiveTerritory: async (parent, { hash, hmac, ...data }, { me, models, lnd }) => {
|
unarchiveTerritory: async (parent, { ...data }, { me, models, lnd }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
}
|
}
|
||||||
@ -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 })
|
||||||
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: {
|
||||||
@ -408,65 +314,9 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
|
async function createSub (parent, data, { me, models, lnd }) {
|
||||||
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 })
|
||||||
// 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' } })
|
||||||
@ -475,7 +325,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash, hmac }) {
|
async function updateSub (parent, { oldName, ...data }, { me, models, lnd }) {
|
||||||
const oldSub = await models.sub.findUnique({
|
const oldSub = await models.sub.findUnique({
|
||||||
where: {
|
where: {
|
||||||
name: oldName,
|
name: oldName,
|
||||||
@ -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 })
|
||||||
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' } })
|
||||||
|
@ -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 },
|
||||||
@ -549,7 +579,8 @@ export default {
|
|||||||
json_build_object('name', 'comments', 'value', COALESCE(SUM(comments), 0)),
|
json_build_object('name', 'comments', 'value', COALESCE(SUM(comments), 0)),
|
||||||
json_build_object('name', 'posts', 'value', COALESCE(SUM(posts), 0)),
|
json_build_object('name', 'posts', 'value', COALESCE(SUM(posts), 0)),
|
||||||
json_build_object('name', 'territories', 'value', COALESCE(SUM(territories), 0)),
|
json_build_object('name', 'territories', 'value', COALESCE(SUM(territories), 0)),
|
||||||
json_build_object('name', 'referrals', 'value', COALESCE(SUM(referrals), 0))
|
json_build_object('name', 'referrals', 'value', COALESCE(SUM(referrals), 0)),
|
||||||
|
json_build_object('name', 'one day referrals', 'value', COALESCE(SUM(one_day_referrals), 0))
|
||||||
) AS data
|
) AS data
|
||||||
FROM ${viewGroup(range, 'user_stats')}
|
FROM ${viewGroup(range, 'user_stats')}
|
||||||
WHERE id = ${me.id}
|
WHERE id = ${me.id}
|
||||||
@ -564,6 +595,7 @@ export default {
|
|||||||
json_build_object('name', 'zaps', 'value', ROUND(COALESCE(SUM(msats_tipped), 0) / 1000)),
|
json_build_object('name', 'zaps', 'value', ROUND(COALESCE(SUM(msats_tipped), 0) / 1000)),
|
||||||
json_build_object('name', 'rewards', 'value', ROUND(COALESCE(SUM(msats_rewards), 0) / 1000)),
|
json_build_object('name', 'rewards', 'value', ROUND(COALESCE(SUM(msats_rewards), 0) / 1000)),
|
||||||
json_build_object('name', 'referrals', 'value', ROUND( COALESCE(SUM(msats_referrals), 0) / 1000)),
|
json_build_object('name', 'referrals', 'value', ROUND( COALESCE(SUM(msats_referrals), 0) / 1000)),
|
||||||
|
json_build_object('name', 'one day referrals', 'value', ROUND( COALESCE(SUM(msats_one_day_referrals), 0) / 1000)),
|
||||||
json_build_object('name', 'territories', 'value', ROUND(COALESCE(SUM(msats_revenue), 0) / 1000))
|
json_build_object('name', 'territories', 'value', ROUND(COALESCE(SUM(msats_revenue), 0) / 1000))
|
||||||
) AS data
|
) AS data
|
||||||
FROM ${viewGroup(range, 'user_stats')}
|
FROM ${viewGroup(range, 'user_stats')}
|
||||||
@ -577,6 +609,7 @@ export default {
|
|||||||
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time,
|
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time,
|
||||||
json_build_array(
|
json_build_array(
|
||||||
json_build_object('name', 'fees', 'value', FLOOR(COALESCE(SUM(msats_fees), 0) / 1000)),
|
json_build_object('name', 'fees', 'value', FLOOR(COALESCE(SUM(msats_fees), 0) / 1000)),
|
||||||
|
json_build_object('name', 'zapping', 'value', FLOOR(COALESCE(SUM(msats_zaps), 0) / 1000)),
|
||||||
json_build_object('name', 'donations', 'value', FLOOR(COALESCE(SUM(msats_donated), 0) / 1000)),
|
json_build_object('name', 'donations', 'value', FLOOR(COALESCE(SUM(msats_donated), 0) / 1000)),
|
||||||
json_build_object('name', 'territories', 'value', FLOOR(COALESCE(SUM(msats_billing), 0) / 1000))
|
json_build_object('name', 'territories', 'value', FLOOR(COALESCE(SUM(msats_billing), 0) / 1000))
|
||||||
) AS data
|
) AS data
|
||||||
|
@ -1,18 +1,40 @@
|
|||||||
import { getIdentity, createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, authenticatedLndGrpc, deletePayment, getPayment } from 'ln-service'
|
import { createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, deletePayment, getPayment, getIdentity } from 'ln-service'
|
||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
import crypto from 'crypto'
|
import crypto, { timingSafeEqual } 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 { msatsToSats, msatsToSatsDecimal } from '@/lib/format'
|
||||||
import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
|
import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
|
||||||
import { CLNAutowithdrawSchema, LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate'
|
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants'
|
||||||
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, Wallet } from '@/lib/constants'
|
|
||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
import assertGofacYourself from './ofac'
|
import assertGofacYourself from './ofac'
|
||||||
import assertApiKeyNotPermitted from './apiKey'
|
import assertApiKeyNotPermitted from './apiKey'
|
||||||
import { createInvoice as createInvoiceCLN } from '@/lib/cln'
|
|
||||||
import { bolt11Tags } from '@/lib/bolt11'
|
import { bolt11Tags } from '@/lib/bolt11'
|
||||||
|
import { checkInvoice } from 'worker/wallet'
|
||||||
|
import walletDefs from 'wallets/server'
|
||||||
|
import { generateResolverName, generateSchema } from '@/lib/wallet'
|
||||||
|
import { lnAddrOptions } from '@/lib/lnurl'
|
||||||
|
|
||||||
|
function injectResolvers (resolvers) {
|
||||||
|
console.group('injected GraphQL resolvers:')
|
||||||
|
for (const w of walletDefs) {
|
||||||
|
const { walletType, walletField, testConnect } = w
|
||||||
|
const resolverName = generateResolverName(walletField)
|
||||||
|
console.log(resolverName)
|
||||||
|
resolvers.Mutation[resolverName] = async (parent, { settings, ...data }, { me, models }) => {
|
||||||
|
return await upsertWallet({
|
||||||
|
schema: generateSchema(w),
|
||||||
|
wallet: { field: walletField, type: walletType },
|
||||||
|
testConnect: (data) =>
|
||||||
|
testConnect(data, { me, models })
|
||||||
|
}, { settings, data }, { me, models })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.groupEnd()
|
||||||
|
|
||||||
|
return resolvers
|
||||||
|
}
|
||||||
|
|
||||||
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({
|
||||||
@ -92,7 +114,7 @@ export function createHmac (hash) {
|
|||||||
return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
|
return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const resolvers = {
|
||||||
Query: {
|
Query: {
|
||||||
invoice: getInvoice,
|
invoice: getInvoice,
|
||||||
wallet: async (parent, { id }, { me, models }) => {
|
wallet: async (parent, { id }, { me, models }) => {
|
||||||
@ -215,6 +237,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 +270,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(
|
||||||
@ -315,9 +339,10 @@ export default {
|
|||||||
where: {
|
where: {
|
||||||
userId: me.id
|
userId: me.id
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: [
|
||||||
createdAt: 'asc'
|
{ createdAt: 'desc' },
|
||||||
}
|
{ id: 'desc' }
|
||||||
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -376,22 +401,13 @@ export default {
|
|||||||
sendToLnAddr,
|
sendToLnAddr,
|
||||||
cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => {
|
cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => {
|
||||||
const hmac2 = createHmac(hash)
|
const hmac2 = createHmac(hash)
|
||||||
if (hmac !== hmac2) {
|
if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
|
||||||
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) {
|
||||||
@ -429,85 +445,6 @@ export default {
|
|||||||
}
|
}
|
||||||
return { id }
|
return { id }
|
||||||
},
|
},
|
||||||
upsertWalletLND: async (parent, { settings, ...data }, { me, models }) => {
|
|
||||||
// make sure inputs are base64
|
|
||||||
data.macaroon = ensureB64(data.macaroon)
|
|
||||||
data.cert = ensureB64(data.cert)
|
|
||||||
|
|
||||||
const wallet = Wallet.LND
|
|
||||||
return await upsertWallet(
|
|
||||||
{
|
|
||||||
schema: LNDAutowithdrawSchema,
|
|
||||||
wallet,
|
|
||||||
testConnect: async ({ cert, macaroon, socket }) => {
|
|
||||||
try {
|
|
||||||
const { lnd } = await authenticatedLndGrpc({
|
|
||||||
cert,
|
|
||||||
macaroon,
|
|
||||||
socket
|
|
||||||
})
|
|
||||||
const inv = await createInvoice({
|
|
||||||
description: 'SN connection test',
|
|
||||||
lnd,
|
|
||||||
tokens: 0,
|
|
||||||
expires_at: new Date()
|
|
||||||
})
|
|
||||||
// we wrap both calls in one try/catch since connection attempts happen on RPC calls
|
|
||||||
await addWalletLog({ wallet, level: 'SUCCESS', message: 'connected to LND' }, { me, models })
|
|
||||||
return inv
|
|
||||||
} catch (err) {
|
|
||||||
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
|
|
||||||
const details = err[2]?.err?.details || err.message || err.toString?.()
|
|
||||||
await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to LND: ${details}` }, { me, models })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ settings, data }, { me, models })
|
|
||||||
},
|
|
||||||
upsertWalletCLN: async (parent, { settings, ...data }, { me, models }) => {
|
|
||||||
data.cert = ensureB64(data.cert)
|
|
||||||
|
|
||||||
const wallet = Wallet.CLN
|
|
||||||
return await upsertWallet(
|
|
||||||
{
|
|
||||||
schema: CLNAutowithdrawSchema,
|
|
||||||
wallet,
|
|
||||||
testConnect: async ({ socket, rune, cert }) => {
|
|
||||||
try {
|
|
||||||
const inv = await createInvoiceCLN({
|
|
||||||
socket,
|
|
||||||
rune,
|
|
||||||
cert,
|
|
||||||
description: 'SN connection test',
|
|
||||||
msats: 'any',
|
|
||||||
expiry: 0
|
|
||||||
})
|
|
||||||
await addWalletLog({ wallet, level: 'SUCCESS', message: 'connected to CLN' }, { me, models })
|
|
||||||
return inv
|
|
||||||
} catch (err) {
|
|
||||||
const details = err.details || err.message || err.toString?.()
|
|
||||||
await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to CLN: ${details}` }, { me, models })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ settings, data }, { me, models })
|
|
||||||
},
|
|
||||||
upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => {
|
|
||||||
const wallet = Wallet.LnAddr
|
|
||||||
return await upsertWallet(
|
|
||||||
{
|
|
||||||
schema: lnAddrAutowithdrawSchema,
|
|
||||||
wallet,
|
|
||||||
testConnect: async ({ address }) => {
|
|
||||||
const options = await lnAddrOptions(address)
|
|
||||||
await addWalletLog({ wallet, level: 'SUCCESS', message: 'fetched payment details' }, { me, models })
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ settings, data }, { me, models })
|
|
||||||
},
|
|
||||||
removeWallet: async (parent, { id }, { me, models }) => {
|
removeWallet: async (parent, { id }, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
@ -520,7 +457,7 @@ export default {
|
|||||||
|
|
||||||
await models.$transaction([
|
await models.$transaction([
|
||||||
models.wallet.delete({ where: { userId: me.id, id: Number(id) } }),
|
models.wallet.delete({ where: { userId: me.id, id: Number(id) } }),
|
||||||
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet deleted' } })
|
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet detached' } })
|
||||||
])
|
])
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@ -545,7 +482,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: {
|
||||||
@ -564,6 +541,8 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default injectResolvers(resolvers)
|
||||||
|
|
||||||
export const addWalletLog = async ({ wallet, level, message }, { me, models }) => {
|
export const addWalletLog = async ({ wallet, level, message }, { me, models }) => {
|
||||||
try {
|
try {
|
||||||
await models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level, message } })
|
await models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level, message } })
|
||||||
@ -586,13 +565,14 @@ async function upsertWallet (
|
|||||||
await testConnect(data)
|
await testConnect(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
await addWalletLog({ wallet, level: 'ERROR', message: 'failed to attach wallet' }, { me, models })
|
const message = err.message || err.toString?.()
|
||||||
throw new GraphQLError('failed to connect to wallet', { extensions: { code: 'BAD_INPUT' } })
|
await addWalletLog({ wallet, level: 'ERROR', message: 'failed to attach: ' + message }, { me, models })
|
||||||
|
throw new GraphQLError(message, { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, ...walletData } = data
|
const { id, ...walletData } = data
|
||||||
const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority } = settings
|
const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, enabled, priority } = settings
|
||||||
|
|
||||||
const txs = [
|
const txs = [
|
||||||
models.user.update({
|
models.user.update({
|
||||||
@ -604,24 +584,13 @@ async function upsertWallet (
|
|||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
if (priority) {
|
|
||||||
txs.push(
|
|
||||||
models.wallet.updateMany({
|
|
||||||
where: {
|
|
||||||
userId: me.id
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
priority: 0
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
txs.push(
|
txs.push(
|
||||||
models.wallet.update({
|
models.wallet.update({
|
||||||
where: { id: Number(id), userId: me.id },
|
where: { id: Number(id), userId: me.id },
|
||||||
data: {
|
data: {
|
||||||
priority: priority ? 1 : 0,
|
enabled,
|
||||||
|
priority,
|
||||||
[wallet.field]: {
|
[wallet.field]: {
|
||||||
update: {
|
update: {
|
||||||
where: { walletId: Number(id) },
|
where: { walletId: Number(id) },
|
||||||
@ -629,25 +598,43 @@ async function upsertWallet (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet updated' } })
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
txs.push(
|
txs.push(
|
||||||
models.wallet.create({
|
models.wallet.create({
|
||||||
data: {
|
data: {
|
||||||
priority: Number(priority),
|
enabled,
|
||||||
|
priority,
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
type: wallet.type,
|
type: wallet.type,
|
||||||
[wallet.field]: {
|
[wallet.field]: {
|
||||||
create: walletData
|
create: walletData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet created' } })
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
txs.push(
|
||||||
|
models.walletLog.createMany({
|
||||||
|
data: {
|
||||||
|
userId: me.id,
|
||||||
|
wallet: wallet.type,
|
||||||
|
level: 'SUCCESS',
|
||||||
|
message: id ? 'wallet updated' : 'wallet attached'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
models.walletLog.create({
|
||||||
|
data: {
|
||||||
|
userId: me.id,
|
||||||
|
wallet: wallet.type,
|
||||||
|
level: enabled ? 'SUCCESS' : 'INFO',
|
||||||
|
message: enabled ? 'wallet enabled' : 'wallet disabled'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
await models.$transaction(txs)
|
await models.$transaction(txs)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -711,12 +698,28 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
|
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
|
||||||
{ me, models, lnd, headers, autoWithdraw = false }) {
|
{ me, models, lnd, headers }) {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||||
}
|
}
|
||||||
assertApiKeyNotPermitted({ me })
|
assertApiKeyNotPermitted({ me })
|
||||||
|
|
||||||
|
const res = await fetchLnAddrInvoice({ addr, amount, maxFee, comment, ...payer },
|
||||||
|
{
|
||||||
|
me,
|
||||||
|
models,
|
||||||
|
lnd
|
||||||
|
})
|
||||||
|
|
||||||
|
// take pr and createWithdrawl
|
||||||
|
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLnAddrInvoice (
|
||||||
|
{ addr, amount, maxFee, comment, ...payer },
|
||||||
|
{
|
||||||
|
me, models, lnd, autoWithdraw = false
|
||||||
|
}) {
|
||||||
const options = await lnAddrOptions(addr)
|
const options = await lnAddrOptions(addr)
|
||||||
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
|
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
|
||||||
|
|
||||||
@ -757,7 +760,7 @@ export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...
|
|||||||
if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
|
if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
|
||||||
// unset lnaddr so we don't trigger another withdrawal with same destination
|
// unset lnaddr so we don't trigger another withdrawal with same destination
|
||||||
await models.wallet.deleteMany({
|
await models.wallet.deleteMany({
|
||||||
where: { userId: me.id, type: Wallet.LnAddr.type }
|
where: { userId: me.id, type: 'LIGHTNING_ADDRESS' }
|
||||||
})
|
})
|
||||||
throw new Error('automated withdrawals to other stackers are not allowed')
|
throw new Error('automated withdrawals to other stackers are not allowed')
|
||||||
}
|
}
|
||||||
@ -769,6 +772,5 @@ export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|
||||||
// take pr and createWithdrawl
|
return res
|
||||||
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers, autoWithdraw })
|
|
||||||
}
|
}
|
||||||
|
@ -54,6 +54,69 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function oneDayReferral (request, { me }) {
|
||||||
|
if (!me) return
|
||||||
|
const refHeader = request.headers['x-stacker-news-referrer']
|
||||||
|
if (!refHeader) return
|
||||||
|
|
||||||
|
const referrers = refHeader.split('; ').filter(Boolean)
|
||||||
|
for (const referrer of referrers) {
|
||||||
|
let prismaPromise, getData
|
||||||
|
|
||||||
|
if (referrer.startsWith('item-')) {
|
||||||
|
prismaPromise = models.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } })
|
||||||
|
getData = item => ({
|
||||||
|
referrerId: item.userId,
|
||||||
|
refereeId: parseInt(me.id),
|
||||||
|
type: item.parentId ? 'COMMENT' : 'POST',
|
||||||
|
typeId: String(item.id)
|
||||||
|
})
|
||||||
|
} else if (referrer.startsWith('profile-')) {
|
||||||
|
const name = referrer.slice(8)
|
||||||
|
// exclude all pages that are not user profiles
|
||||||
|
if (['api', 'auth', 'day', 'invites', 'invoices', 'referrals', 'rewards',
|
||||||
|
'satistics', 'settings', 'stackers', 'wallet', 'withdrawals', '404', '500',
|
||||||
|
'email', 'live', 'login', 'notifications', 'offline', 'search', 'share',
|
||||||
|
'signup', 'territory', 'recent', 'top', 'edit', 'post', 'rss', 'saloon',
|
||||||
|
'faq', 'story', 'privacy', 'copyright', 'tos', 'changes', 'guide', 'daily',
|
||||||
|
'anon', 'ad'].includes(name)) continue
|
||||||
|
|
||||||
|
prismaPromise = models.user.findUnique({ where: { name } })
|
||||||
|
getData = user => ({
|
||||||
|
referrerId: user.id,
|
||||||
|
refereeId: parseInt(me.id),
|
||||||
|
type: 'PROFILE',
|
||||||
|
typeId: String(user.id)
|
||||||
|
})
|
||||||
|
} else if (referrer.startsWith('territory-')) {
|
||||||
|
prismaPromise = models.sub.findUnique({ where: { name: referrer.slice(10) } })
|
||||||
|
getData = sub => ({
|
||||||
|
referrerId: sub.userId,
|
||||||
|
refereeId: parseInt(me.id),
|
||||||
|
type: 'TERRITORY',
|
||||||
|
typeId: sub.name
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
prismaPromise = models.user.findUnique({ where: { name: referrer } })
|
||||||
|
getData = user => ({
|
||||||
|
referrerId: user.id,
|
||||||
|
refereeId: parseInt(me.id),
|
||||||
|
type: 'REFERRAL',
|
||||||
|
typeId: String(user.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
prismaPromise?.then(ref => {
|
||||||
|
if (ref && getData) {
|
||||||
|
const data = getData(ref)
|
||||||
|
// can't refer yourself
|
||||||
|
if (data.refereeId === data.referrerId) return
|
||||||
|
models.oneDayReferral.create({ data }).catch(console.error)
|
||||||
|
}
|
||||||
|
}).catch(console.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes a query and variables and returns a getServerSideProps function
|
* Takes a query and variables and returns a getServerSideProps function
|
||||||
*
|
*
|
||||||
@ -124,6 +187,8 @@ export function getGetServerSideProps (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oneDayReferral(req, { me })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
...props,
|
...props,
|
||||||
|
@ -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]
|
||||||
|
@ -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]): 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]): 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, 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): 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], 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): ItemPaidAction!
|
||||||
act(id: ID!, sats: Int, act: String, idempotent: Boolean, hash: String, hmac: String): ItemActResult!
|
act(id: ID!, sats: Int, act: String, idempotent: Boolean): ItemActPaidAction!
|
||||||
pollVote(id: ID!, hash: String, hmac: String): ID!
|
pollVote(id: ID!): 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 {
|
||||||
|
@ -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!
|
||||||
@ -83,6 +89,19 @@ export default gql`
|
|||||||
sources: EarnSources
|
sources: EarnSources
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReferralSources {
|
||||||
|
id: ID!
|
||||||
|
forever: Int!
|
||||||
|
oneDay: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReferralReward {
|
||||||
|
id: ID!
|
||||||
|
earnedSats: Int!
|
||||||
|
sortTime: Date!
|
||||||
|
sources: ReferralSources
|
||||||
|
}
|
||||||
|
|
||||||
type Revenue {
|
type Revenue {
|
||||||
id: ID!
|
id: ID!
|
||||||
earnedSats: Int!
|
earnedSats: Int!
|
||||||
@ -136,7 +155,8 @@ 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
|
||||||
|
| ReferralReward
|
||||||
|
|
||||||
type Notifications {
|
type Notifications {
|
||||||
lastChecked: Date
|
lastChecked: Date
|
||||||
|
54
api/typeDefs/paidAction.js
Normal file
54
api/typeDefs/paidAction.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
|
export default gql`
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
paidAction(invoiceId: Int!): PaidAction
|
||||||
|
}
|
||||||
|
|
||||||
|
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!
|
||||||
|
}
|
||||||
|
|
||||||
|
`
|
@ -2,12 +2,6 @@ import { gql } from 'graphql-tag'
|
|||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
referrals(when: String, from: String, to: String): Referrals!
|
referrals(when: String, from: String, to: String): [TimeData!]!
|
||||||
}
|
|
||||||
|
|
||||||
type Referrals {
|
|
||||||
totalSats: Int!
|
|
||||||
totalReferrals: Int!
|
|
||||||
stats: [TimeData!]!
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -7,7 +7,11 @@ export default gql`
|
|||||||
}
|
}
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
donateToRewards(sats: Int!, hash: String, hmac: String): Int!
|
donateToRewards(sats: Int!): DonatePaidAction!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DonateResult {
|
||||||
|
sats: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Rewards {
|
type Rewards {
|
||||||
|
@ -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!, nsfw: Boolean!): SubPaidAction!
|
||||||
paySub(name: String!, hash: String, hmac: String): Sub
|
paySub(name: 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!, nsfw: Boolean!): SubPaidAction!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Sub {
|
type Sub {
|
||||||
|
@ -1,6 +1,32 @@
|
|||||||
import { gql } from 'graphql-tag'
|
import { gql } from 'graphql-tag'
|
||||||
|
import { generateResolverName } from '@/lib/wallet'
|
||||||
|
|
||||||
export default gql`
|
import walletDefs from 'wallets/server'
|
||||||
|
|
||||||
|
function injectTypeDefs (typeDefs) {
|
||||||
|
console.group('injected GraphQL type defs:')
|
||||||
|
const injected = walletDefs.map(
|
||||||
|
(w) => {
|
||||||
|
let args = 'id: ID, '
|
||||||
|
args += w.fields.map(f => {
|
||||||
|
let arg = `${f.name}: String`
|
||||||
|
if (!f.optional) {
|
||||||
|
arg += '!'
|
||||||
|
}
|
||||||
|
return arg
|
||||||
|
}).join(', ')
|
||||||
|
args += ', settings: AutowithdrawSettings!'
|
||||||
|
const resolverName = generateResolverName(w.walletField)
|
||||||
|
const typeDef = `${resolverName}(${args}): Boolean`
|
||||||
|
console.log(typeDef)
|
||||||
|
return typeDef
|
||||||
|
})
|
||||||
|
console.groupEnd()
|
||||||
|
|
||||||
|
return `${typeDefs}\n\nextend type Mutation {\n${injected.join('\n')}\n}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeDefs = `
|
||||||
extend type Query {
|
extend type Query {
|
||||||
invoice(id: ID!): Invoice!
|
invoice(id: ID!): Invoice!
|
||||||
withdrawl(id: ID!): Withdrawl!
|
withdrawl(id: ID!): Withdrawl!
|
||||||
@ -19,9 +45,6 @@ export default gql`
|
|||||||
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
|
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
|
||||||
cancelInvoice(hash: String!, hmac: String!): Invoice!
|
cancelInvoice(hash: String!, hmac: String!): Invoice!
|
||||||
dropBolt11(id: ID): Withdrawl
|
dropBolt11(id: ID): Withdrawl
|
||||||
upsertWalletLND(id: ID, socket: String!, macaroon: String!, cert: String, settings: AutowithdrawSettings!): Boolean
|
|
||||||
upsertWalletCLN(id: ID, socket: String!, rune: String!, cert: String, settings: AutowithdrawSettings!): Boolean
|
|
||||||
upsertWalletLNAddr(id: ID, address: String!, settings: AutowithdrawSettings!): Boolean
|
|
||||||
removeWallet(id: ID!): Boolean
|
removeWallet(id: ID!): Boolean
|
||||||
deleteWalletLogs(wallet: String): Boolean
|
deleteWalletLogs(wallet: String): Boolean
|
||||||
}
|
}
|
||||||
@ -30,7 +53,8 @@ export default gql`
|
|||||||
id: ID!
|
id: ID!
|
||||||
createdAt: Date!
|
createdAt: Date!
|
||||||
type: String!
|
type: String!
|
||||||
priority: Boolean!
|
enabled: Boolean!
|
||||||
|
priority: Int!
|
||||||
wallet: WalletDetails!
|
wallet: WalletDetails!
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +79,8 @@ export default gql`
|
|||||||
input AutowithdrawSettings {
|
input AutowithdrawSettings {
|
||||||
autoWithdrawThreshold: Int!
|
autoWithdrawThreshold: Int!
|
||||||
autoWithdrawMaxFeePercent: Float!
|
autoWithdrawMaxFeePercent: Float!
|
||||||
priority: Boolean!
|
priority: Int
|
||||||
|
enabled: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Invoice {
|
type Invoice {
|
||||||
@ -74,6 +99,11 @@ export default gql`
|
|||||||
hmac: String
|
hmac: String
|
||||||
isHeld: Boolean
|
isHeld: Boolean
|
||||||
confirmedPreimage: String
|
confirmedPreimage: String
|
||||||
|
actionState: String
|
||||||
|
actionType: String
|
||||||
|
actionError: String
|
||||||
|
item: Item
|
||||||
|
itemAct: ItemAct
|
||||||
}
|
}
|
||||||
|
|
||||||
type Withdrawl {
|
type Withdrawl {
|
||||||
@ -118,3 +148,5 @@ export default gql`
|
|||||||
message: String!
|
message: String!
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export default gql`${injectTypeDefs(typeDefs)}`
|
||||||
|
@ -110,3 +110,8 @@ SatsAllDay,pr,#1197,#1192,medium,,,,250k,weareallsatoshi@getalby.com,2024-06-03
|
|||||||
tsmith123,pr,#1216,#1213,easy,,1,,90k,stickymarch60@walletofsatoshi.com,2024-06-03
|
tsmith123,pr,#1216,#1213,easy,,1,,90k,stickymarch60@walletofsatoshi.com,2024-06-03
|
||||||
tsmith123,pr,#1231,#1230,good-first-issue,,,,20k,stickymarch60@walletofsatoshi.com,2024-06-13
|
tsmith123,pr,#1231,#1230,good-first-issue,,,,20k,stickymarch60@walletofsatoshi.com,2024-06-13
|
||||||
felipebueno,issue,#1231,#1230,good-first-issue,,,,2k,felipebueno@getalby.com,2024-06-13
|
felipebueno,issue,#1231,#1230,good-first-issue,,,,2k,felipebueno@getalby.com,2024-06-13
|
||||||
|
tsmith123,pr,#1223,#107,medium,,2,10k bonus for our slowness,210k,stickymarch60@walletofsatoshi.com,2024-06-22
|
||||||
|
cointastical,issue,#1223,#107,medium,,2,,20k,cointastical@stacker.news,2024-06-22
|
||||||
|
kravhen,pr,#1215,#253,medium,,2,upgraded to medium,200k,nichro@getalby.com,2024-06-28
|
||||||
|
dillon-co,pr,#1140,#633,hard,,,requested advance,500k,bolt11,2024-07-02
|
||||||
|
takitakitanana,issue,,#1257,good-first-issue,,,,2k,takitakitanana@stacker.news,2024-07-11
|
||||||
|
|
@ -15,12 +15,19 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText
|
|||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
placement={placement || 'bottom'}
|
placement={placement || 'bottom'}
|
||||||
overlay={
|
overlay={
|
||||||
<Tooltip>
|
<Tooltip style={{ position: 'fixed' }}>
|
||||||
{overlayText}
|
{overlayText}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
trigger={['hover', 'focus']}
|
trigger={['hover', 'focus']}
|
||||||
show={formik?.isSubmitting ? false : undefined}
|
show={formik?.isSubmitting ? false : undefined}
|
||||||
|
popperConfig={{
|
||||||
|
modifiers: {
|
||||||
|
preventOverflow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{children}
|
{children}
|
||||||
|
@ -8,15 +8,14 @@ function autoWithdrawThreshold ({ me }) {
|
|||||||
return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
|
return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
|
||||||
}
|
}
|
||||||
|
|
||||||
export function autowithdrawInitial ({ me, priority = false }) {
|
export function autowithdrawInitial ({ me }) {
|
||||||
return {
|
return {
|
||||||
priority,
|
|
||||||
autoWithdrawThreshold: autoWithdrawThreshold({ me }),
|
autoWithdrawThreshold: autoWithdrawThreshold({ me }),
|
||||||
autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1
|
autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutowithdrawSettings ({ priority }) {
|
export function AutowithdrawSettings ({ wallet }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const threshold = autoWithdrawThreshold({ me })
|
const threshold = autoWithdrawThreshold({ me })
|
||||||
|
|
||||||
@ -29,9 +28,10 @@ export function AutowithdrawSettings ({ priority }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label='make default autowithdraw method'
|
disabled={!wallet.isConfigured}
|
||||||
id='priority'
|
label='enabled'
|
||||||
name='priority'
|
id='enabled'
|
||||||
|
name='enabled'
|
||||||
/>
|
/>
|
||||||
<div className='my-4 border border-3 rounded'>
|
<div className='my-4 border border-3 rounded'>
|
||||||
<div className='p-3'>
|
<div className='p-3'>
|
||||||
@ -46,12 +46,14 @@ export function AutowithdrawSettings ({ priority }) {
|
|||||||
}}
|
}}
|
||||||
hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined}
|
hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined}
|
||||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label='max fee'
|
label='max fee'
|
||||||
name='autoWithdrawMaxFeePercent'
|
name='autoWithdrawMaxFeePercent'
|
||||||
hint='max fee as percent of withdrawal amount'
|
hint='max fee as percent of withdrawal amount'
|
||||||
append={<InputGroup.Text>%</InputGroup.Text>}
|
append={<InputGroup.Text>%</InputGroup.Text>}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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
|
||||||
|
@ -156,7 +156,7 @@ export function WhenComposedChart ({
|
|||||||
data,
|
data,
|
||||||
lineNames = [], lineAxis = 'left',
|
lineNames = [], lineAxis = 'left',
|
||||||
areaNames = [], areaAxis = 'left',
|
areaNames = [], areaAxis = 'left',
|
||||||
barNames = [], barAxis = 'left'
|
barNames = [], barAxis = 'left', barStackId
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
@ -189,7 +189,7 @@ export function WhenComposedChart ({
|
|||||||
<Tooltip labelFormatter={labelFormatter(when, from, to)} contentStyle={{ color: 'var(--bs-body-color)', backgroundColor: 'var(--bs-body-bg)' }} />
|
<Tooltip labelFormatter={labelFormatter(when, from, to)} contentStyle={{ color: 'var(--bs-body-color)', backgroundColor: 'var(--bs-body-bg)' }} />
|
||||||
<Legend />
|
<Legend />
|
||||||
{barNames?.map((v, i) =>
|
{barNames?.map((v, i) =>
|
||||||
<Bar yAxisId={barAxis} key={v} type='monotone' dataKey={v} name={v} stroke={getColor(i)} fill={getColor(i)} />)}
|
<Bar yAxisId={barAxis} key={v} stackId={barStackId} type='monotone' dataKey={v} name={v} stroke={getColor(i)} fill={getColor(i)} />)}
|
||||||
{areaNames?.map((v, i) =>
|
{areaNames?.map((v, i) =>
|
||||||
<Area yAxisId={areaAxis} key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={getColor(barNames.length + i)} fill={getColor(barNames.length + i)} />)}
|
<Area yAxisId={areaAxis} key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={getColor(barNames.length + i)} fill={getColor(barNames.length + i)} />)}
|
||||||
{lineNames?.map((v, i) =>
|
{lineNames?.map((v, i) =>
|
||||||
|
@ -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)))
|
|
||||||
}
|
|
||||||
}
|
|
@ -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'
|
||||||
|
@ -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()
|
||||||
@ -78,7 +77,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
|
|||||||
</div>)
|
</div>)
|
||||||
: <div />}
|
: <div />}
|
||||||
<LinkToContext
|
<LinkToContext
|
||||||
className={siblingComments ? 'py-3' : 'py-2'}
|
className='py-2'
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
router.push(href, as)
|
router.push(href, as)
|
||||||
}}
|
}}
|
||||||
@ -137,14 +136,17 @@ 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}`}>
|
||||||
<ZapIcon item={item} pin={pin} me={me} />
|
{item.outlawed && !me?.privates?.wildWestMode
|
||||||
|
? <Skull className={styles.dontLike} width={24} height={24} />
|
||||||
|
: item.meDontLikeSats > item.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} />}
|
||||||
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
{item.user?.meMute && !includeParent && collapse === 'yep'
|
{item.user?.meMute && !includeParent && collapse === 'yep'
|
||||||
@ -242,24 +244,9 @@ export default function Comment ({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ItemContextProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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}>
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
.item {
|
.item {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
padding-bottom: 0 !important;
|
padding-top: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upvote {
|
.upvote {
|
||||||
margin-top: 9px;
|
margin-top: 9px;
|
||||||
margin-left: .25rem;
|
padding-right: 0.2rem;
|
||||||
margin-right: 0rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pin {
|
.pin {
|
||||||
@ -65,7 +64,7 @@
|
|||||||
|
|
||||||
.children {
|
.children {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-left: 30px;
|
margin-left: 27px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments {
|
.comments {
|
||||||
@ -109,7 +108,7 @@
|
|||||||
.comment {
|
.comment {
|
||||||
border-radius: .4rem;
|
border-radius: .4rem;
|
||||||
padding-top: .5rem;
|
padding-top: .5rem;
|
||||||
padding-left: .2rem;
|
padding-left: .7rem;
|
||||||
background-color: var(--theme-commentBg);
|
background-color: var(--theme-commentBg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,7 +128,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.comment:not(:first-of-type) {
|
.comment:not(:first-of-type) {
|
||||||
padding-top: .25rem;
|
padding-top: 0;
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
border-top-right-radius: 0;
|
border-top-right-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment:has(.comment) + .comment{
|
||||||
|
padding-top: .5rem;
|
||||||
|
}
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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`
|
||||||
|
: ' '}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
@ -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()
|
||||||
|
@ -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}
|
||||||
>
|
>
|
||||||
|
@ -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={
|
||||||
|
@ -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>) ||
|
||||||
|
@ -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
|
|
||||||
try {
|
|
||||||
if (onSubmit) {
|
|
||||||
if (requireSession && !me) {
|
if (requireSession && !me) {
|
||||||
throw new SessionRequiredError()
|
throw new SessionRequiredError()
|
||||||
}
|
}
|
||||||
|
|
||||||
revert = optimisticUpdate?.(variables)
|
try {
|
||||||
|
if (onSubmit) {
|
||||||
await signal?.pause({ me, amount })
|
await onSubmit(variables, ...args)
|
||||||
|
|
||||||
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)
|
||||||
|
toaster.danger(err.message ?? err.toString?.())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const reason = err.message || err.toString?.()
|
if (!storageKeyPrefix) return
|
||||||
if (me && clientNotification) {
|
clearLocalStorage(values)
|
||||||
notify(clientNotification.ERROR, { ...variables, reason })
|
}, [me, onSubmit, clearLocalStorage, storageKeyPrefix])
|
||||||
} 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])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
|
@ -56,3 +56,17 @@
|
|||||||
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%;
|
||||||
|
}
|
||||||
|
}
|
@ -26,11 +26,20 @@ export default function HoverablePopover ({ id, trigger, body, onShow }) {
|
|||||||
show={showOverlay}
|
show={showOverlay}
|
||||||
placement='bottom'
|
placement='bottom'
|
||||||
onHide={handleMouseLeave}
|
onHide={handleMouseLeave}
|
||||||
|
popperConfig={{
|
||||||
|
modifiers: {
|
||||||
|
preventOverflow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
overlay={
|
overlay={
|
||||||
<Popover
|
<Popover
|
||||||
onPointerEnter={handleMouseEnter}
|
onPointerEnter={handleMouseEnter}
|
||||||
onPointerLeave={handleMouseLeave}
|
onPointerLeave={handleMouseLeave}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
className={styles.HoverablePopover}
|
className={styles.HoverablePopover}
|
||||||
|
style={{ position: 'fixed' }}
|
||||||
>
|
>
|
||||||
<Popover.Body className={styles.HoverablePopover}>
|
<Popover.Body className={styles.HoverablePopover}>
|
||||||
{body}
|
{body}
|
||||||
|
@ -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} />
|
||||||
}
|
}
|
||||||
|
@ -1,37 +1,52 @@
|
|||||||
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 { NoAttachedWalletError } from './payment'
|
import { NoAttachedWalletError } 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, useWallet, walletError, poll }) {
|
export default function Invoice ({ id, query = INVOICE, modal, onPayment, onCanceled, info, successVerb, useWallet = true, walletError, 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)
|
||||||
|
}
|
||||||
|
if (invoice.cancelled || invoice.actionError) {
|
||||||
|
onCanceled?.(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 useWallet was not passed, use true by default
|
if (!invoice) {
|
||||||
if (useWallet === undefined) useWallet = true
|
return <QrSkeleton {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
let variant = 'default'
|
let variant = 'default'
|
||||||
let status = 'waiting for you'
|
let status = 'waiting for you'
|
||||||
@ -48,43 +63,31 @@ export default function Invoice ({ invoice, modal, onPayment, info, successVerb,
|
|||||||
variant = 'failed'
|
variant = 'failed'
|
||||||
status = 'expired'
|
status = 'expired'
|
||||||
useWallet = false
|
useWallet = 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 (
|
||||||
<>
|
<>
|
||||||
{walletError && !(walletError instanceof NoAttachedWalletError) &&
|
{walletError && !(walletError instanceof NoAttachedWalletError) &&
|
||||||
<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>{walletError.toString()}</div>
|
<code> {walletError.message}</code>
|
||||||
</div>}
|
</div>}
|
||||||
<Qr
|
<Qr
|
||||||
useWallet={useWallet} value={invoice.bolt11}
|
useWallet={useWallet} 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 +120,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -5,14 +5,12 @@ 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 { 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,57 +48,7 @@ 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
|
|
||||||
|
|
||||||
cache.modify({
|
|
||||||
id: `Item:${id}`,
|
|
||||||
fields: {
|
|
||||||
sats (existingSats = 0) {
|
|
||||||
if (act === 'TIP') {
|
|
||||||
return existingSats + sats
|
|
||||||
}
|
|
||||||
|
|
||||||
return existingSats
|
|
||||||
},
|
|
||||||
meSats: (existingSats = 0) => {
|
|
||||||
if (act === 'TIP') {
|
|
||||||
return existingSats + sats
|
|
||||||
}
|
|
||||||
|
|
||||||
return existingSats
|
|
||||||
},
|
|
||||||
meDontLikeSats: me
|
|
||||||
? (existingSats = 0) => {
|
|
||||||
if (act === 'DONT_LIKE_THIS') {
|
|
||||||
return existingSats + sats
|
|
||||||
}
|
|
||||||
|
|
||||||
return existingSats
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (act === 'TIP') {
|
|
||||||
// update all ancestors
|
|
||||||
path.split('.').forEach(aId => {
|
|
||||||
if (Number(aId) === Number(id)) return
|
|
||||||
cache.modify({
|
|
||||||
id: `Item:${aId}`,
|
|
||||||
fields: {
|
|
||||||
commentSats (existingCommentSats = 0) {
|
|
||||||
return existingCommentSats + sats
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdate?.(cache, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ItemAct ({ onClose, item, down, children, abortSignal, optimisticUpdate }) {
|
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const [oValue, setOValue] = useState()
|
const [oValue, setOValue] = useState()
|
||||||
@ -110,35 +58,54 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal, o
|
|||||||
}, [onClose, item.id])
|
}, [onClose, item.id])
|
||||||
|
|
||||||
const act = useAct()
|
const act = useAct()
|
||||||
|
const strike = useLightning()
|
||||||
|
|
||||||
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
|
const onSubmit = useCallback(async ({ amount }) => {
|
||||||
await act({
|
if (abortSignal && zapUndoTrigger({ me, amount })) {
|
||||||
|
onClose?.()
|
||||||
|
try {
|
||||||
|
await abortSignal.pause({ me, amount })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ActCanceledError) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { error } = await act({
|
||||||
variables: {
|
variables: {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
sats: Number(amount),
|
sats: Number(amount),
|
||||||
act: down ? 'DONT_LIKE_THIS' : 'TIP',
|
act: down ? 'DONT_LIKE_THIS' : 'TIP'
|
||||||
hash,
|
|
||||||
hmac
|
|
||||||
},
|
},
|
||||||
update: actUpdate({ me })
|
optimisticResponse: me
|
||||||
})
|
? {
|
||||||
|
act: {
|
||||||
|
__typename: 'ItemActPaidAction',
|
||||||
|
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: () => {
|
||||||
|
strike()
|
||||||
|
onClose?.()
|
||||||
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (error) throw error
|
||||||
addCustomTip(Number(amount))
|
addCustomTip(Number(amount))
|
||||||
}, [me, act, down, item.id])
|
}, [me, act, down, item.id, onClose, abortSignal, strike])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientNotifyProvider additionalProps={{ itemId: item.id }}>
|
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
amount: me?.privates?.tipDefault || defaultTips[0],
|
amount: me?.privates?.tipDefault || defaultTips[0],
|
||||||
default: false
|
default: false
|
||||||
}}
|
}}
|
||||||
schema={amountSchema}
|
schema={amountSchema}
|
||||||
prepaid
|
|
||||||
optimisticUpdate={({ amount }) => optimisticUpdate(amount, { onClose })}
|
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
clientNotification={ClientNotification.Zap}
|
|
||||||
signal={abortSignal}
|
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
label='amount'
|
label='amount'
|
||||||
@ -158,58 +125,37 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal, o
|
|||||||
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
|
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</ClientNotifyProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ACT_MUTATION = gql`
|
function modifyActCache (cache, { result, invoice }) {
|
||||||
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
|
if (!result) return
|
||||||
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
|
const { id, sats, path, act } = result
|
||||||
id
|
|
||||||
sats
|
|
||||||
path
|
|
||||||
act
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
export function useAct ({ onUpdate } = {}) {
|
|
||||||
const [act] = useMutation(ACT_MUTATION)
|
|
||||||
return act
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useZap () {
|
|
||||||
const update = useCallback((cache, args) => {
|
|
||||||
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({
|
cache.modify({
|
||||||
id: `Item:${id}`,
|
id: `Item:${id}`,
|
||||||
fields: {
|
fields: {
|
||||||
sats (existingSats = 0) {
|
sats (existingSats = 0) {
|
||||||
return existingSats + satsDelta
|
if (act === 'TIP') {
|
||||||
|
return existingSats + sats
|
||||||
|
}
|
||||||
|
return existingSats
|
||||||
},
|
},
|
||||||
meSats: () => {
|
meSats: (existingSats = 0) => {
|
||||||
return sats
|
if (act === 'TIP') {
|
||||||
|
return existingSats + sats
|
||||||
|
}
|
||||||
|
return existingSats
|
||||||
|
},
|
||||||
|
meDontLikeSats: (existingSats = 0) => {
|
||||||
|
if (act === 'DONT_LIKE_THIS') {
|
||||||
|
return existingSats + sats
|
||||||
|
}
|
||||||
|
return existingSats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (act === 'TIP') {
|
||||||
// update all ancestors
|
// update all ancestors
|
||||||
path.split('.').forEach(aId => {
|
path.split('.').forEach(aId => {
|
||||||
if (Number(aId) === Number(id)) return
|
if (Number(aId) === Number(id)) return
|
||||||
@ -217,80 +163,73 @@ export function useZap () {
|
|||||||
id: `Item:${aId}`,
|
id: `Item:${aId}`,
|
||||||
fields: {
|
fields: {
|
||||||
commentSats (existingCommentSats = 0) {
|
commentSats (existingCommentSats = 0) {
|
||||||
return existingCommentSats + satsDelta
|
return existingCommentSats + sats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [])
|
}
|
||||||
|
|
||||||
const ZAP_MUTATION = gql`
|
export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
|
||||||
mutation idempotentAct($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
|
// because the mutation name we use varies,
|
||||||
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac, idempotent: true) {
|
// we need to extract the result/invoice from the response
|
||||||
id
|
const getPaidActionResult = data => Object.values(data)[0]
|
||||||
sats
|
|
||||||
path
|
const [act] = usePaidMutation(query, {
|
||||||
|
...options,
|
||||||
|
update: (cache, { data }) => {
|
||||||
|
const response = getPaidActionResult(data)
|
||||||
|
if (!response) return
|
||||||
|
modifyActCache(cache, response)
|
||||||
|
options?.update?.(cache, { data })
|
||||||
|
},
|
||||||
|
onPayError: (e, cache, { data }) => {
|
||||||
|
const response = getPaidActionResult(data)
|
||||||
|
if (!response || !response.result) return
|
||||||
|
const { result: { sats } } = response
|
||||||
|
const negate = { ...response, result: { ...response.result, sats: -1 * sats } }
|
||||||
|
modifyActCache(cache, negate)
|
||||||
|
options?.onPayError?.(e, cache, { data })
|
||||||
|
},
|
||||||
|
onPaid: (cache, { data }) => {
|
||||||
|
const response = getPaidActionResult(data)
|
||||||
|
if (!response) return
|
||||||
|
options?.onPaid?.(cache, { data })
|
||||||
}
|
}
|
||||||
}`
|
})
|
||||||
const [zap] = useMutation(ZAP_MUTATION)
|
return act
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useZap () {
|
||||||
|
const act = useAct()
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const { notify, unnotify } = useClientNotifications()
|
|
||||||
|
|
||||||
const toaster = useToast()
|
|
||||||
const strike = useLightning()
|
const strike = useLightning()
|
||||||
const payment = usePayment()
|
const toaster = useToast()
|
||||||
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: { __typename: 'ItemActPaidAction', 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 })
|
||||||
|
strike()
|
||||||
await abortSignal.pause({ me, amount: satsDelta })
|
const { error } = await act({ variables, optimisticResponse })
|
||||||
|
if (error) throw error
|
||||||
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 ActCanceledError) {
|
||||||
if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const reason = error?.message || error?.toString?.()
|
const reason = error?.message || error?.toString?.()
|
||||||
if (me) {
|
toaster.danger(reason)
|
||||||
notify(ClientNotification.Zap.ERROR, { ...notifyProps, reason })
|
|
||||||
} else {
|
|
||||||
toaster.danger('zap failed: ' + reason)
|
|
||||||
}
|
}
|
||||||
|
}, [me?.id, strike])
|
||||||
cancel?.()
|
|
||||||
} finally {
|
|
||||||
if (nid) unnotify(nid)
|
|
||||||
}
|
|
||||||
}, [me?.id, strike, payment, notify, unnotify, pendingSats])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
@ -106,8 +106,24 @@ function ItemEmbed ({ item }) {
|
|||||||
<div className={styles.videoContainer}>
|
<div className={styles.videoContainer}>
|
||||||
<iframe
|
<iframe
|
||||||
title='Rumble Video'
|
title='Rumble Video'
|
||||||
allowFullScreen=''
|
allowFullScreen
|
||||||
src={meta?.href}
|
src={meta?.href}
|
||||||
|
sandbox='allow-scripts'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'peertube') {
|
||||||
|
return (
|
||||||
|
<div className={styles.videoWrapper}>
|
||||||
|
<div className={styles.videoContainer}>
|
||||||
|
<iframe
|
||||||
|
title='PeerTube Video'
|
||||||
|
allowFullScreen
|
||||||
|
src={meta?.href}
|
||||||
|
sandbox='allow-scripts'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -22,22 +22,25 @@ 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'
|
||||||
|
import { useToast } from './toast'
|
||||||
|
|
||||||
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 toaster = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [canEdit, setCanEdit] =
|
const [canEdit, setCanEdit] =
|
||||||
useState(item.mine && (Date.now() < editThreshold))
|
useState(item.mine && (Date.now() < editThreshold))
|
||||||
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 +50,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 +65,60 @@ 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 () => {
|
||||||
|
try {
|
||||||
|
const { error } = await retryCreateItem({ variables: { invoiceId: parseInt(item.invoice?.id) } })
|
||||||
|
if (error) throw error
|
||||||
|
} catch (error) {
|
||||||
|
toaster.danger(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 +130,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 +152,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,24 +204,10 @@ 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 &&
|
||||||
|
<>
|
||||||
|
<EditInfo />
|
||||||
<ActionDropdown>
|
<ActionDropdown>
|
||||||
<CopyLinkDropdownItem item={item} />
|
<CopyLinkDropdownItem item={item} />
|
||||||
{(item.parentId || item.text) && onQuoteReply &&
|
{(item.parentId || item.text) && onQuoteReply &&
|
||||||
@ -181,7 +227,7 @@ export default function ItemInfo ({
|
|||||||
<CrosspostDropdownItem item={item} />}
|
<CrosspostDropdownItem item={item} />}
|
||||||
{me && !item.position &&
|
{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 &&
|
||||||
@ -189,6 +235,13 @@ export default function ItemInfo ({
|
|||||||
<hr className='dropdown-divider' />
|
<hr className='dropdown-divider' />
|
||||||
<OutlawDropdownItem item={item} />
|
<OutlawDropdownItem item={item} />
|
||||||
</>}
|
</>}
|
||||||
|
{item.mine && item.invoice?.id &&
|
||||||
|
<>
|
||||||
|
<hr className='dropdown-divider' />
|
||||||
|
<Link href={`/invoices/${item.invoice?.id}`} className='text-reset dropdown-item'>
|
||||||
|
view invoice
|
||||||
|
</Link>
|
||||||
|
</>}
|
||||||
{me && !nested && !item.mine && sub && Number(me.id) !== Number(sub.userId) &&
|
{me && !nested && !item.mine && sub && Number(me.id) !== Number(sub.userId) &&
|
||||||
<>
|
<>
|
||||||
<hr className='dropdown-divider' />
|
<hr className='dropdown-divider' />
|
||||||
@ -210,6 +263,7 @@ export default function ItemInfo ({
|
|||||||
<MuteDropdownItem user={item.user} />
|
<MuteDropdownItem user={item.user} />
|
||||||
</>}
|
</>}
|
||||||
</ActionDropdown>
|
</ActionDropdown>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
{extraInfo}
|
{extraInfo}
|
||||||
</div>
|
</div>
|
||||||
|
@ -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,68 +45,31 @@ export function SearchTitle ({ title }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const ItemContext = createContext({
|
export default function Item ({
|
||||||
pendingSats: 0,
|
item, rank, belowTitle, right, full, children, itemClassName,
|
||||||
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}>
|
||||||
{rank}
|
{rank}
|
||||||
</div>)
|
</div>)
|
||||||
: <div />}
|
: <div />}
|
||||||
<div className={`${styles.item} ${siblingComments ? 'pt-3' : ''}`}>
|
<div className={classNames(styles.item, itemClassName)}>
|
||||||
<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))
|
||||||
|
@ -118,7 +118,7 @@ a.link:visited {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding-bottom: .5rem;
|
padding-top: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item .companyImage {
|
.item .companyImage {
|
||||||
@ -169,7 +169,8 @@ a.link:visited {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.children {
|
.children {
|
||||||
margin-left: 28px;
|
margin-left: 27px;
|
||||||
|
padding-top: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank {
|
.rank {
|
||||||
|
@ -51,7 +51,7 @@ export default function Items ({ ssrData, variables = {}, query, destructureData
|
|||||||
<>
|
<>
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{itemsWithPins.filter(filter).map((item, i) => (
|
{itemsWithPins.filter(filter).map((item, i) => (
|
||||||
<ListItem key={item.id} item={item} rank={rank && i + 1} siblingComments={variables.includeComments} pinnable={isHome ? false : pins?.length > 0} />
|
<ListItem key={item.id} item={item} rank={rank && i + 1} itemClassName={variables.includeComments ? 'py-2' : ''} pinnable={isHome ? false : pins?.length > 0} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Foooter
|
<Foooter
|
||||||
|
@ -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'>
|
||||||
|
@ -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}
|
||||||
>
|
>
|
||||||
|
@ -12,6 +12,10 @@
|
|||||||
|
|
||||||
.linkBoxParent {
|
.linkBoxParent {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
margin-left: -0.5rem;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
margin-right: -0.5rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.linkBoxParent > * {
|
.linkBoxParent > * {
|
||||||
|
@ -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) {
|
||||||
|
@ -33,7 +33,7 @@ export default function MoreFooter ({ cursor, count, fetchMore, Skeleton, invisi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={`d-flex justify-content-center mt-3 mb-1 ${invisible ? 'invisible' : ''}`}><Footer /></div>
|
return <div className={`d-flex justify-content-center mt-4 mb-1 ${invisible ? 'invisible' : ''}`}><Footer /></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavigateFooter ({ cursor, count, fetchMore, href, text, invisible, noMoreText = 'NO MORE' }) {
|
export function NavigateFooter ({ cursor, count, fetchMore, href, text, invisible, noMoreText = 'NO MORE' }) {
|
||||||
|
@ -6,7 +6,7 @@ import BackArrow from '../../svgs/arrow-left-line.svg'
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import Price from '../price'
|
import Price from '../price'
|
||||||
import SubSelect from '../sub-select'
|
import SubSelect from '../sub-select'
|
||||||
import { USER_ID, BALANCE_LIMIT_MSATS, Wallet } from '../../lib/constants'
|
import { USER_ID, BALANCE_LIMIT_MSATS } from '../../lib/constants'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import NoteIcon from '../../svgs/notification-4-fill.svg'
|
import NoteIcon from '../../svgs/notification-4-fill.svg'
|
||||||
import { useMe } from '../me'
|
import { useMe } from '../me'
|
||||||
@ -22,7 +22,7 @@ import SearchIcon from '../../svgs/search-line.svg'
|
|||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import SnIcon from '@/svgs/sn.svg'
|
import SnIcon from '@/svgs/sn.svg'
|
||||||
import { useHasNewNotes } from '../use-has-new-notes'
|
import { useHasNewNotes } from '../use-has-new-notes'
|
||||||
import { useWalletLogger } from '@/components/wallet-logger'
|
import { useWallets } from 'wallets'
|
||||||
|
|
||||||
export function Brand ({ className }) {
|
export function Brand ({ className }) {
|
||||||
return (
|
return (
|
||||||
@ -256,8 +256,7 @@ export default function LoginButton ({ className }) {
|
|||||||
|
|
||||||
export function LogoutDropdownItem () {
|
export function LogoutDropdownItem () {
|
||||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||||
// const wallet = useWallet()
|
const wallets = useWallets()
|
||||||
const { deleteLogs } = useWalletLogger()
|
|
||||||
return (
|
return (
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@ -266,12 +265,9 @@ export function LogoutDropdownItem () {
|
|||||||
if (pushSubscription) {
|
if (pushSubscription) {
|
||||||
await togglePushSubscription().catch(console.error)
|
await togglePushSubscription().catch(console.error)
|
||||||
}
|
}
|
||||||
// TODO: detach wallets
|
|
||||||
// await wallet.detachAll().catch(console.error)
|
await wallets.resetClient().catch(console.error)
|
||||||
// delete client wallet logs to prevent leak of private data if a shared device was used
|
|
||||||
await deleteLogs(Wallet.NWC).catch(console.error)
|
|
||||||
await deleteLogs(Wallet.LNbits).catch(console.error)
|
|
||||||
await deleteLogs(Wallet.LNC).catch(console.error)
|
|
||||||
await signOut({ callbackUrl: '/' })
|
await signOut({ callbackUrl: '/' })
|
||||||
}}
|
}}
|
||||||
>logout
|
>logout
|
||||||
|
@ -6,7 +6,7 @@ export default function SecondBar (props) {
|
|||||||
const { prefix, topNavKey, sub } = props
|
const { prefix, topNavKey, sub } = props
|
||||||
if (!hasNavSelect(props)) return null
|
if (!hasNavSelect(props)) return null
|
||||||
return (
|
return (
|
||||||
<Navbar className='pt-0 pb-3'>
|
<Navbar className='pt-0 pb-2'>
|
||||||
<Nav
|
<Nav
|
||||||
className={styles.navbarNav}
|
className={styles.navbarNav}
|
||||||
activeKey={topNavKey}
|
activeKey={topNavKey}
|
||||||
|
@ -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'
|
||||||
@ -10,6 +10,7 @@ import { dayMonthYear, timeSince } from '@/lib/time'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Check from '@/svgs/check-double-line.svg'
|
import Check from '@/svgs/check-double-line.svg'
|
||||||
import HandCoin from '@/svgs/hand-coin-fill.svg'
|
import HandCoin from '@/svgs/hand-coin-fill.svg'
|
||||||
|
import UserAdd from '@/svgs/user-add-fill.svg'
|
||||||
import { LOST_BLURBS, FOUND_BLURBS, UNKNOWN_LINK_REL } from '@/lib/constants'
|
import { LOST_BLURBS, FOUND_BLURBS, UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||||
import CowboyHatIcon from '@/svgs/cowboy.svg'
|
import CowboyHatIcon from '@/svgs/cowboy.svg'
|
||||||
import BaldIcon from '@/svgs/bald.svg'
|
import BaldIcon from '@/svgs/bald.svg'
|
||||||
@ -29,15 +30,20 @@ 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'
|
||||||
|
import { useToast } from './toast'
|
||||||
|
|
||||||
function Notification ({ n, fresh }) {
|
function Notification ({ n, fresh }) {
|
||||||
const type = n.__typename
|
const type = n.__typename
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
|
<NotificationLayout nid={nid(n)} type={type} {...defaultOnClick(n)} fresh={fresh}>
|
||||||
{
|
{
|
||||||
(type === 'Earn' && <EarnNotification n={n} />) ||
|
(type === 'Earn' && <EarnNotification n={n} />) ||
|
||||||
(type === 'Revenue' && <RevenueNotification n={n} />) ||
|
(type === 'Revenue' && <RevenueNotification n={n} />) ||
|
||||||
@ -57,32 +63,19 @@ 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} />) ||
|
||||||
|
(type === 'ReferralReward' && <ReferralReward n={n} />)
|
||||||
}
|
}
|
||||||
</NotificationLayout>
|
</NotificationLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClientNotification ({ n }) {
|
function NotificationLayout ({ children, type, nid, href, as, fresh }) {
|
||||||
// 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 }) {
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div>
|
if (!href) return <div className={`py-2 ${fresh ? styles.fresh : ''}`}>{children}</div>
|
||||||
return (
|
return (
|
||||||
<LinkToContext
|
<LinkToContext
|
||||||
className={`${fresh ? styles.fresh : ''} ${router?.query?.nid === nid ? 'outline-it' : ''}`}
|
className={`py-2 ${type === 'Reply' ? styles.reply : ''} ${fresh ? styles.fresh : ''} ${router?.query?.nid === nid ? 'outline-it' : ''}`}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
nid && await router.replace({
|
nid && await router.replace({
|
||||||
@ -101,6 +94,27 @@ function NotificationLayout ({ children, nid, href, as, fresh }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NoteHeader ({ color, children, big }) {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.noteHeader} text-${color} ${big ? '' : 'small'} pb-2`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteItem ({ item }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{item.title
|
||||||
|
? <Item item={item} itemClassName='pt-0' />
|
||||||
|
: (
|
||||||
|
<RootProvider root={item.root}>
|
||||||
|
<Comment item={item} noReply includeParent clickToContext />
|
||||||
|
</RootProvider>)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const defaultOnClick = n => {
|
const defaultOnClick = n => {
|
||||||
const type = n.__typename
|
const type = n.__typename
|
||||||
if (type === 'Earn') {
|
if (type === 'Earn') {
|
||||||
@ -111,36 +125,44 @@ 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 === 'ReferralReward') return { href: '/referrals/month' }
|
||||||
if (type === 'Streak') return {}
|
if (type === 'Streak') return {}
|
||||||
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
|
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
|
||||||
|
|
||||||
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 }) {
|
||||||
@ -158,7 +180,7 @@ function Streak ({ n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='d-flex ms-2 py-1'>
|
<div className='d-flex'>
|
||||||
<div style={{ fontSize: '2rem' }}>{n.days ? <BaldIcon className='fill-grey' height={40} width={40} /> : <CowboyHatIcon className='fill-grey' height={40} width={40} />}</div>
|
<div style={{ fontSize: '2rem' }}>{n.days ? <BaldIcon className='fill-grey' height={40} width={40} /> : <CowboyHatIcon className='fill-grey' height={40} width={40} />}</div>
|
||||||
<div className='ms-1 p-1'>
|
<div className='ms-1 p-1'>
|
||||||
<span className='fw-bold'>you {n.days ? 'lost your' : 'found a'} cowboy hat</span>
|
<span className='fw-bold'>you {n.days ? 'lost your' : 'found a'} cowboy hat</span>
|
||||||
@ -172,12 +194,12 @@ function EarnNotification ({ n }) {
|
|||||||
const time = n.minSortTime === n.sortTime ? dayMonthYear(new Date(n.minSortTime)) : `${dayMonthYear(new Date(n.minSortTime))} to ${dayMonthYear(new Date(n.sortTime))}`
|
const time = n.minSortTime === n.sortTime ? dayMonthYear(new Date(n.minSortTime)) : `${dayMonthYear(new Date(n.minSortTime))} to ${dayMonthYear(new Date(n.sortTime))}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='d-flex ms-2 py-1'>
|
<div className='d-flex'>
|
||||||
<HandCoin className='align-self-center fill-boost mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} />
|
<HandCoin className='align-self-center fill-boost mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} />
|
||||||
<div className='ms-2'>
|
<div className='ms-2'>
|
||||||
<div className='fw-bold text-boost'>
|
<NoteHeader color='boost' big>
|
||||||
you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in rewards<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{time}</small>
|
you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in rewards<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{time}</small>
|
||||||
</div>
|
</NoteHeader>
|
||||||
{n.sources &&
|
{n.sources &&
|
||||||
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
|
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
|
||||||
{n.sources.posts > 0 && <span>{numWithUnits(n.sources.posts, { abbreviate: false })} for top posts</span>}
|
{n.sources.posts > 0 && <span>{numWithUnits(n.sources.posts, { abbreviate: false })} for top posts</span>}
|
||||||
@ -186,7 +208,29 @@ function EarnNotification ({ n }) {
|
|||||||
{n.sources.tipComments > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0 || n.sources.tipPosts > 0) && ' \\ '}{numWithUnits(n.sources.tipComments, { abbreviate: false })} for zapping top comments early</span>}
|
{n.sources.tipComments > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0 || n.sources.tipPosts > 0) && ' \\ '}{numWithUnits(n.sources.tipComments, { abbreviate: false })} for zapping top comments early</span>}
|
||||||
</div>}
|
</div>}
|
||||||
<div style={{ lineHeight: '140%' }}>
|
<div style={{ lineHeight: '140%' }}>
|
||||||
SN distributes the sats it earns back to its best stackers. These sats come from <Link href='/~jobs'>jobs</Link>, boosts, posting fees, and donations. You can see the rewards pool and make a donation <Link href='/rewards'>here</Link>.
|
SN distributes the sats it earns to top stackers like you daily. The top stackers make the top posts and comments or zap the top posts and comments early and generously. View the rewards pool and make a donation <Link href='/rewards'>here</Link>.
|
||||||
|
</div>
|
||||||
|
<small className='text-muted ms-1 pb-1 fw-normal'>click for details</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReferralReward ({ n }) {
|
||||||
|
return (
|
||||||
|
<div className='d-flex'>
|
||||||
|
<UserAdd className='align-self-center fill-success mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} />
|
||||||
|
<div className='ms-2'>
|
||||||
|
<NoteHeader color='success' big>
|
||||||
|
you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in referral rewards<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{dayMonthYear(new Date(n.sortTime))}</small>
|
||||||
|
</NoteHeader>
|
||||||
|
{n.sources &&
|
||||||
|
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
|
||||||
|
{n.sources.forever > 0 && <span>{numWithUnits(n.sources.forever, { abbreviate: false })} for stackers joining because of you</span>}
|
||||||
|
{n.sources.oneDay > 0 && <span>{n.sources.forever > 0 && ' \\ '}{numWithUnits(n.sources.oneDay, { abbreviate: false })} for stackers referred to content by you today</span>}
|
||||||
|
</div>}
|
||||||
|
<div style={{ lineHeight: '140%' }}>
|
||||||
|
SN gives referral rewards to stackers like you for referring the top stackers daily. You refer stackers when they visit your posts, comments, profile, territory, or if they visit SN through your referral links.
|
||||||
</div>
|
</div>
|
||||||
<small className='text-muted ms-1 pb-1 fw-normal'>click for details</small>
|
<small className='text-muted ms-1 pb-1 fw-normal'>click for details</small>
|
||||||
</div>
|
</div>
|
||||||
@ -196,9 +240,9 @@ function EarnNotification ({ n }) {
|
|||||||
|
|
||||||
function RevenueNotification ({ n }) {
|
function RevenueNotification ({ n }) {
|
||||||
return (
|
return (
|
||||||
<div className='d-flex ms-2 py-1'>
|
<div className='d-flex'>
|
||||||
<BountyIcon className='align-self-center fill-success mx-1' width={24} height={24} style={{ flex: '0 0 24px' }} />
|
<BountyIcon className='align-self-center fill-success mx-1' width={24} height={24} style={{ flex: '0 0 24px' }} />
|
||||||
<div className='ms-2 pb-1'>
|
<div className=' pb-1'>
|
||||||
<div className='fw-bold text-success'>
|
<div className='fw-bold text-success'>
|
||||||
you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in territory revenue<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in territory revenue<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||||
</div>
|
</div>
|
||||||
@ -213,7 +257,7 @@ function RevenueNotification ({ n }) {
|
|||||||
function SubStatus ({ n }) {
|
function SubStatus ({ n }) {
|
||||||
const dueDate = nextBillingWithGrace(n.sub)
|
const dueDate = nextBillingWithGrace(n.sub)
|
||||||
return (
|
return (
|
||||||
<div className={`fw-bold text-${n.sub.status === 'ACTIVE' ? 'success' : 'danger'} ms-2`}>
|
<div className={`fw-bold text-${n.sub.status === 'ACTIVE' ? 'success' : 'danger'} `}>
|
||||||
{n.sub.status === 'ACTIVE'
|
{n.sub.status === 'ACTIVE'
|
||||||
? 'your territory is active again'
|
? 'your territory is active again'
|
||||||
: (n.sub.status === 'GRACE'
|
: (n.sub.status === 'GRACE'
|
||||||
@ -227,14 +271,14 @@ function SubStatus ({ n }) {
|
|||||||
function Invitification ({ n }) {
|
function Invitification ({ n }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<small className='fw-bold text-secondary ms-2'>
|
<NoteHeader color='secondary'>
|
||||||
your invite has been redeemed by
|
your invite has been redeemed by
|
||||||
{numWithUnits(n.invite.invitees.length, {
|
{numWithUnits(n.invite.invitees.length, {
|
||||||
abbreviate: false,
|
abbreviate: false,
|
||||||
unitSingular: 'stacker',
|
unitSingular: 'stacker',
|
||||||
unitPlural: 'stackers'
|
unitPlural: 'stackers'
|
||||||
})}
|
})}
|
||||||
</small>
|
</NoteHeader>
|
||||||
<div className='ms-4 me-2 mt-1'>
|
<div className='ms-4 me-2 mt-1'>
|
||||||
<Invite
|
<Invite
|
||||||
invite={n.invite} active={
|
invite={n.invite} active={
|
||||||
@ -252,8 +296,7 @@ function NostrZap ({ n }) {
|
|||||||
const { npub, content, note } = nostrZapDetails(nostr)
|
const { npub, content, note } = nostrZapDetails(nostr)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='fw-bold text-nostr'>
|
||||||
<div className='fw-bold text-nostr ms-2 py-1'>
|
|
||||||
<NostrIcon width={24} height={24} className='fill-nostr me-1' />{numWithUnits(n.earnedSats)} zap from
|
<NostrIcon width={24} height={24} className='fill-nostr me-1' />{numWithUnits(n.earnedSats)} zap from
|
||||||
{// eslint-disable-next-line
|
{// eslint-disable-next-line
|
||||||
<Link className='mx-1 text-reset text-underline' target='_blank' href={`https://njump.me/${npub}`} rel={UNKNOWN_LINK_REL}>
|
<Link className='mx-1 text-reset text-underline' target='_blank' href={`https://njump.me/${npub}`} rel={UNKNOWN_LINK_REL}>
|
||||||
@ -270,7 +313,6 @@ function NostrZap ({ n }) {
|
|||||||
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||||
{content && <small className='d-block ms-4 ps-1 mt-1 mb-1 text-muted fw-normal'><Text>{content}</Text></small>}
|
{content && <small className='d-block ms-4 ps-1 mt-1 mb-1 text-muted fw-normal'><Text>{content}</Text></small>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -288,7 +330,7 @@ function InvoicePaid ({ n }) {
|
|||||||
if (id) payerSig += id
|
if (id) payerSig += id
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className='fw-bold text-info ms-2 py-1'>
|
<div className='fw-bold text-info'>
|
||||||
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account
|
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account
|
||||||
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||||
{n.invoice.comment &&
|
{n.invoice.comment &&
|
||||||
@ -300,9 +342,122 @@ 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 toaster = useToast()
|
||||||
|
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 = 'info'
|
||||||
|
switch (invoiceActionState) {
|
||||||
|
case 'FAILED':
|
||||||
|
actionString += 'failed'
|
||||||
|
colorClass = 'warning'
|
||||||
|
break
|
||||||
|
case 'PAID':
|
||||||
|
actionString += 'paid'
|
||||||
|
colorClass = 'success'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
actionString += 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<NoteHeader color={colorClass}>
|
||||||
|
{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={async () => {
|
||||||
|
try {
|
||||||
|
const { error } = await retry({ variables: { invoiceId: parseInt(invoiceId) } })
|
||||||
|
if (error) throw error
|
||||||
|
} catch (error) {
|
||||||
|
toaster.danger(error?.message || error?.toString?.())
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
retry
|
||||||
|
</Button>
|
||||||
|
<span className='text-muted ms-2 fw-normal' suppressHydrationWarning>{timeSince(new Date(sortTime))}</span>
|
||||||
|
</span>
|
||||||
|
</NoteHeader>
|
||||||
|
<NoteItem item={invoice.item} />
|
||||||
|
</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'>
|
||||||
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account
|
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account
|
||||||
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||||
{n.withdrawl.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>}
|
{n.withdrawl.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>}
|
||||||
@ -312,8 +467,8 @@ function WithdrawlPaid ({ n }) {
|
|||||||
|
|
||||||
function Referral ({ n }) {
|
function Referral ({ n }) {
|
||||||
return (
|
return (
|
||||||
<small className='fw-bold text-secondary ms-2'>
|
<small className='fw-bold text-success'>
|
||||||
someone joined via one of your referral links
|
<UserAdd className='fill-success me-2' height={21} width={21} style={{ transform: 'rotateY(180deg)' }} />someone joined SN because of you
|
||||||
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||||
</small>
|
</small>
|
||||||
)
|
)
|
||||||
@ -334,25 +489,15 @@ function Votification ({ n }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<small className='fw-bold text-success d-inline-block ms-2 my-1' style={{ lineHeight: '1.25' }}>
|
<NoteHeader color='success'>
|
||||||
your {n.item.title ? 'post' : 'reply'} stacked {numWithUnits(n.earnedSats, { abbreviate: false })}
|
your {n.item.title ? 'post' : 'reply'} stacked {numWithUnits(n.earnedSats, { abbreviate: false })}
|
||||||
{n.item.forwards?.length > 0 &&
|
{n.item.forwards?.length > 0 &&
|
||||||
<>
|
<>
|
||||||
{' '}and forwarded {numWithUnits(forwardedSats, { abbreviate: false })} to{' '}
|
{' '}and forwarded {numWithUnits(forwardedSats, { abbreviate: false })} to{' '}
|
||||||
<ForwardedUsers />
|
<ForwardedUsers />
|
||||||
</>}
|
</>}
|
||||||
</small>
|
</NoteHeader>
|
||||||
<div>
|
<NoteItem item={n.item} />
|
||||||
{n.item.title
|
|
||||||
? <Item item={n.item} />
|
|
||||||
: (
|
|
||||||
<div className='pb-2'>
|
|
||||||
<RootProvider root={n.item.root}>
|
|
||||||
<Comment item={n.item} noReply includeParent clickToContext />
|
|
||||||
</RootProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -360,20 +505,10 @@ function Votification ({ n }) {
|
|||||||
function ForwardedVotification ({ n }) {
|
function ForwardedVotification ({ n }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<small className='fw-bold text-success d-inline-block ms-2 my-1' style={{ lineHeight: '1.25' }}>
|
<NoteHeader color='success'>
|
||||||
you were forwarded {numWithUnits(n.earnedSats, { abbreviate: false })} from
|
you were forwarded {numWithUnits(n.earnedSats, { abbreviate: false })} from
|
||||||
</small>
|
</NoteHeader>
|
||||||
<div>
|
<NoteItem item={n.item} />
|
||||||
{n.item.title
|
|
||||||
? <Item item={n.item} />
|
|
||||||
: (
|
|
||||||
<div className='pb-2'>
|
|
||||||
<RootProvider root={n.item.root}>
|
|
||||||
<Comment item={n.item} noReply includeParent clickToContext />
|
|
||||||
</RootProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -381,19 +516,10 @@ function ForwardedVotification ({ n }) {
|
|||||||
function Mention ({ n }) {
|
function Mention ({ n }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<small className='fw-bold text-info ms-2'>
|
<NoteHeader color='info'>
|
||||||
you were mentioned in
|
you were mentioned in
|
||||||
</small>
|
</NoteHeader>
|
||||||
<div>
|
<NoteItem item={n.item} />
|
||||||
{n.item.title
|
|
||||||
? <Item item={n.item} />
|
|
||||||
: (
|
|
||||||
<div className='pb-2'>
|
|
||||||
<RootProvider root={n.item.root}>
|
|
||||||
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying on:' : undefined} clickToContext />
|
|
||||||
</RootProvider>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -401,19 +527,10 @@ function Mention ({ n }) {
|
|||||||
function ItemMention ({ n }) {
|
function ItemMention ({ n }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<small className='fw-bold text-info ms-2'>
|
<NoteHeader color='info'>
|
||||||
your item was mentioned in
|
your item was mentioned in
|
||||||
</small>
|
</NoteHeader>
|
||||||
<div>
|
<NoteItem item={n.item} />
|
||||||
{n.item?.title
|
|
||||||
? <Item item={n.item} />
|
|
||||||
: (
|
|
||||||
<div className='pb-2'>
|
|
||||||
<RootProvider root={n.item.root}>
|
|
||||||
<Comment item={n.item} noReply includeParent rootText='replying on:' clickToContext />
|
|
||||||
</RootProvider>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -421,49 +538,29 @@ function ItemMention ({ n }) {
|
|||||||
function JobChanged ({ n }) {
|
function JobChanged ({ n }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<small className={`fw-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ms-1`}>
|
<NoteHeader color={n.item.status === 'ACTIVE' ? 'success' : 'boost'}>
|
||||||
{n.item.status === 'ACTIVE'
|
{n.item.status === 'ACTIVE'
|
||||||
? 'your job is active again'
|
? 'your job is active again'
|
||||||
: (n.item.status === 'NOSATS'
|
: (n.item.status === 'NOSATS'
|
||||||
? 'your job promotion ran out of sats'
|
? 'your job promotion ran out of sats'
|
||||||
: 'your job has been stopped')}
|
: 'your job has been stopped')}
|
||||||
</small>
|
</NoteHeader>
|
||||||
<ItemJob item={n.item} />
|
<ItemJob item={n.item} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Reply ({ n }) {
|
function Reply ({ n }) {
|
||||||
return (
|
return <NoteItem item={n.item} />
|
||||||
<div className='py-2'>
|
|
||||||
{n.item.title
|
|
||||||
? <Item item={n.item} />
|
|
||||||
: (
|
|
||||||
<div className='pb-2'>
|
|
||||||
<RootProvider root={n.item.root}>
|
|
||||||
<Comment item={n.item} noReply includeParent clickToContext rootText='replying on:' />
|
|
||||||
</RootProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FollowActivity ({ n }) {
|
function FollowActivity ({ n }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<small className='fw-bold text-info ms-2'>
|
<NoteHeader color='info'>
|
||||||
a stacker you subscribe to {n.item.parentId ? 'commented' : 'posted'}
|
a stacker you subscribe to {n.item.parentId ? 'commented' : 'posted'}
|
||||||
</small>
|
</NoteHeader>
|
||||||
{n.item.title
|
<NoteItem item={n.item} />
|
||||||
? <div className='ms-2'><Item item={n.item} /></div>
|
|
||||||
: (
|
|
||||||
<div className='pb-2'>
|
|
||||||
<RootProvider root={n.item.root}>
|
|
||||||
<Comment item={n.item} noReply includeParent clickToContext rootText='replying on:' />
|
|
||||||
</RootProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -471,11 +568,11 @@ function FollowActivity ({ n }) {
|
|||||||
function TerritoryPost ({ n }) {
|
function TerritoryPost ({ n }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<small className='fw-bold text-info ms-2'>
|
<NoteHeader color='info'>
|
||||||
new post in ~{n.item.sub.name}
|
new post in ~{n.item.sub.name}
|
||||||
</small>
|
</NoteHeader>
|
||||||
<div>
|
<div>
|
||||||
<Item item={n.item} />
|
<Item item={n.item} itemClassName='pt-0' />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@ -483,28 +580,20 @@ function TerritoryPost ({ n }) {
|
|||||||
|
|
||||||
function TerritoryTransfer ({ n }) {
|
function TerritoryTransfer ({ n }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='fw-bold text-info '>
|
||||||
<div className='fw-bold text-info ms-2'>
|
|
||||||
~{n.sub.name} was transferred to you
|
~{n.sub.name} was transferred to you
|
||||||
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Reminder ({ n }) {
|
function Reminder ({ n }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<small className='fw-bold text-info ms-2'>you asked to be reminded of this {n.item.title ? 'post' : 'comment'}</small>
|
<NoteHeader color='info'>
|
||||||
{n.item.title
|
you asked to be reminded of this {n.item.title ? 'post' : 'comment'}
|
||||||
? <div className='ms-2'><Item item={n.item} /></div>
|
</NoteHeader>
|
||||||
: (
|
<NoteItem item={n.item} />
|
||||||
<div className='pb-2'>
|
|
||||||
<RootProvider root={n.item.root}>
|
|
||||||
<Comment item={n.item} noReply includeParent clickToContext rootText='replying on:' />
|
|
||||||
</RootProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -574,7 +663,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 +690,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)}
|
||||||
|
@ -1,14 +1,43 @@
|
|||||||
.fresh {
|
.fresh {
|
||||||
background-color: rgba(128, 128, 128, 0.1);
|
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
border: solid 1px var(--theme-note-fresh);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fresh:not(.fresh ~ .fresh) {
|
.fresh:not(.fresh ~ .fresh) {
|
||||||
border-top-left-radius: .4rem;
|
border-top-left-radius: .4rem;
|
||||||
border-top-right-radius: .4rem;
|
border-top-right-radius: .4rem;
|
||||||
|
border-top: solid 1px var(--theme-note-fresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fresh:has(+ :not(.fresh)):has(+ :not(.reply)),
|
||||||
|
.fresh:not(.reply):has(+ :not(.fresh)) {
|
||||||
|
border-bottom-left-radius: .4rem;
|
||||||
|
border-bottom-right-radius: .4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fresh:has(+ :not(.fresh)) {
|
.fresh:has(+ :not(.fresh)) {
|
||||||
|
border-bottom: solid 1px var(--theme-note-fresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply {
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: var(--theme-note-reply);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply:hover {
|
||||||
|
background-color: var(--theme-clickToContextColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply:not(.fresh):not(.reply + .reply) {
|
||||||
|
border-top-left-radius: .4rem;
|
||||||
|
border-top-right-radius: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply:not(.fresh):has(+ :not(.reply)) {
|
||||||
border-bottom-left-radius: .4rem;
|
border-bottom-left-radius: .4rem;
|
||||||
border-bottom-right-radius: .4rem;
|
border-bottom-right-radius: .4rem;
|
||||||
}
|
}
|
||||||
@ -34,3 +63,10 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.noteHeader {
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
@ -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,15 @@ 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 { 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 +24,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;
|
|
||||||
[{ hash, hmac }, cancel] = await payment.request(sats)
|
|
||||||
|
|
||||||
await act({
|
|
||||||
variables: { hash, hmac, ...variables },
|
|
||||||
optimisticResponse,
|
|
||||||
update: actUpdate({ me, onUpdate: onUpdate(onComplete) })
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PayBounty ({ children, item }) {
|
||||||
|
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: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } },
|
||||||
|
...payBountyCacheMods
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePayBounty = async onCompleted => {
|
||||||
|
try {
|
||||||
|
strike()
|
||||||
|
const { error } = await act({ onCompleted })
|
||||||
|
if (error) throw error
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof InvoiceCanceledError) {
|
if (error instanceof ActCanceledError) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const reason = error?.message || error?.toString?.()
|
const reason = error?.message || error?.toString?.()
|
||||||
if (me) {
|
toaster.danger(reason)
|
||||||
notify(ClientNotification.Bounty.ERROR, { ...notifyProps, reason })
|
|
||||||
} else {
|
|
||||||
toaster.danger('pay bounty failed: ' + reason)
|
|
||||||
}
|
|
||||||
cancel?.()
|
|
||||||
} finally {
|
|
||||||
if (nid) unnotify(nid)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
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 { useWallet } from './wallet'
|
import { useWallet } from 'wallets'
|
||||||
import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
|
import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
|
||||||
import { INVOICE } from '@/fragments/wallet'
|
import { INVOICE } from '@/fragments/wallet'
|
||||||
import Invoice from '@/components/invoice'
|
import Invoice from '@/components/invoice'
|
||||||
@ -9,9 +9,11 @@ import { useFeeButton } from './fee-button'
|
|||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
|
|
||||||
export class InvoiceCanceledError extends Error {
|
export class InvoiceCanceledError extends Error {
|
||||||
constructor (hash) {
|
constructor (hash, actionError) {
|
||||||
super(`invoice canceled: ${hash}`)
|
super(actionError ?? `invoice canceled: ${hash}`)
|
||||||
this.name = 'InvoiceCanceledError'
|
this.name = 'InvoiceCanceledError'
|
||||||
|
this.hash = hash
|
||||||
|
this.actionError = actionError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,7 +31,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 +62,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, actionError } = data.invoice
|
||||||
// if we're polling for invoices, we're using JIT invoices so isHeld must be set
|
|
||||||
if (isHeld && satsReceived) {
|
if (cancelled || actionError) {
|
||||||
return true
|
throw new InvoiceCanceledError(hash, actionError)
|
||||||
}
|
}
|
||||||
if (cancelled) {
|
|
||||||
throw new InvoiceCanceledError(hash)
|
return that(data.invoice)
|
||||||
}
|
|
||||||
return false
|
|
||||||
}, [client])
|
}, [client])
|
||||||
|
|
||||||
const waitUntilPaid = useCallback(async id => {
|
const waitController = useMemo(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const signal = controller.signal
|
||||||
|
controller.wait = async ({ id }, waitFor = inv => (inv.satsReceived > 0)) => {
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const paid = await isPaid(id)
|
const paid = await isInvoice({ id }, waitFor)
|
||||||
if (paid) {
|
if (paid) {
|
||||||
resolve()
|
resolve()
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
|
signal.removeEventListener('abort', abort)
|
||||||
|
} else {
|
||||||
|
console.info(`invoice #${id}: waiting for payment ...`)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err)
|
reject(err)
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
|
signal.removeEventListener('abort', abort)
|
||||||
}
|
}
|
||||||
}, FAST_POLL_INTERVAL)
|
}, FAST_POLL_INTERVAL)
|
||||||
|
|
||||||
|
const abort = () => {
|
||||||
|
console.info(`invoice #${id}: stopped waiting`)
|
||||||
|
resolve()
|
||||||
|
clearInterval(interval)
|
||||||
|
signal.removeEventListener('abort', abort)
|
||||||
|
}
|
||||||
|
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 useWalletPayment = () => {
|
export const useWalletPayment = () => {
|
||||||
const invoice = useInvoice()
|
const invoice = useInvoice()
|
||||||
const wallet = useWallet()
|
const wallet = useWallet()
|
||||||
|
|
||||||
const waitForWalletPayment = useCallback(async ({ id, bolt11 }) => {
|
const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor) => {
|
||||||
if (!wallet) {
|
if (!wallet) {
|
||||||
throw new NoAttachedWalletError()
|
throw new NoAttachedWalletError()
|
||||||
}
|
}
|
||||||
@ -119,42 +143,57 @@ const useWalletPayment = () => {
|
|||||||
// 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('payment failed:', err)
|
console.error('payment failed:', err)
|
||||||
throw err
|
throw err
|
||||||
|
} finally {
|
||||||
|
invoice.stopWaiting()
|
||||||
}
|
}
|
||||||
}, [wallet, invoice])
|
}, [wallet, invoice])
|
||||||
|
|
||||||
return waitForWalletPayment
|
return waitForWalletPayment
|
||||||
}
|
}
|
||||||
|
|
||||||
const useQrPayment = () => {
|
export const useQrPayment = () => {
|
||||||
const invoice = useInvoice()
|
const invoice = useInvoice()
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
|
|
||||||
const waitForQrPayment = useCallback(async (inv, walletError) => {
|
const waitForQrPayment = useCallback(async (inv, walletError,
|
||||||
|
{
|
||||||
|
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'
|
||||||
useWallet={false}
|
useWallet={false}
|
||||||
walletError={walletError}
|
walletError={walletError}
|
||||||
|
waitFor={waitFor}
|
||||||
|
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv?.hash, inv?.actionError)) }}
|
||||||
onPayment={() => { paid = true; onClose(); resolve() }}
|
onPayment={() => { paid = true; onClose(); resolve() }}
|
||||||
poll
|
poll
|
||||||
/>,
|
/>,
|
||||||
{ keepOpen: true, onClose: cancelAndReject })
|
{ keepOpen, persistOnNavigate, onClose: cancelAndReject })
|
||||||
})
|
})
|
||||||
}, [invoice])
|
}, [invoice])
|
||||||
|
|
||||||
|
@ -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}
|
||||||
>
|
>
|
||||||
|
@ -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 { 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,16 @@ 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: { __typename: 'PollVotePaidAction', result: { id: v.id } } }
|
||||||
const optimisticResponse = { pollVote: v.id }
|
|
||||||
let cancel, nid
|
|
||||||
try {
|
try {
|
||||||
setPendingVote(v.id)
|
const { error } = await pollVote({
|
||||||
|
variables,
|
||||||
if (me) {
|
optimisticResponse
|
||||||
nid = notify(ClientNotification.PollVote.PENDING, notifyProps)
|
})
|
||||||
}
|
if (error) throw error
|
||||||
|
|
||||||
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) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const reason = error?.message || error?.toString?.()
|
const reason = error?.message || error?.toString?.()
|
||||||
if (me) {
|
toaster.danger(reason)
|
||||||
notify(ClientNotification.PollVote.ERROR, { ...notifyProps, reason })
|
|
||||||
} else {
|
|
||||||
toaster.danger('poll vote failed: ' + reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel?.()
|
|
||||||
} finally {
|
|
||||||
setPendingVote(undefined)
|
|
||||||
if (nid) unnotify(nid)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: signIn}
|
: signIn}
|
||||||
@ -94,11 +43,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 +81,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 +102,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
|
||||||
|
}
|
||||||
|
@ -2,8 +2,7 @@ import QRCode from 'qrcode.react'
|
|||||||
import { CopyInput, InputSkeleton } from './form'
|
import { CopyInput, InputSkeleton } from './form'
|
||||||
import InvoiceStatus from './invoice-status'
|
import InvoiceStatus from './invoice-status'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useWallet } from './wallet'
|
import { useWallet } from 'wallets'
|
||||||
import SimpleCountdown from './countdown'
|
|
||||||
import Bolt11Info from './bolt11-info'
|
import Bolt11Info from './bolt11-info'
|
||||||
|
|
||||||
export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) {
|
export default function Qr ({ asIs, value, useWallet: automated, 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 />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -15,7 +15,7 @@ export default function RecentHeader ({ type, sub }) {
|
|||||||
|
|
||||||
type ||= router.query.type || type || 'posts'
|
type ||= router.query.type || type || 'posts'
|
||||||
return (
|
return (
|
||||||
<div className='text-muted fw-bold mt-1 mb-3 d-flex justify-content-start align-items-center'>
|
<div className='text-muted fw-bold my-1 d-flex justify-content-start align-items-center'>
|
||||||
<Select
|
<Select
|
||||||
groupClassName='mb-2'
|
groupClassName='mb-2'
|
||||||
className='w-auto'
|
className='w-auto'
|
||||||
|
@ -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 }) => {
|
||||||
|
|
||||||
const onSubmit = useCallback(async ({ amount, hash, hmac, ...values }, { resetForm }) => {
|
|
||||||
const { data } = await upsertComment({ variables: { parentId, hash, hmac, ...values } })
|
|
||||||
toastUpsertSuccessMessages(toaster, data, 'upsertComment', false, values.text)
|
|
||||||
resetForm({ text: '' })
|
resetForm({ text: '' })
|
||||||
setReply(replyOpen || false)
|
setReply(replyOpen || false)
|
||||||
}, [upsertComment, setReply, parentId])
|
},
|
||||||
|
navigateOnSubmit: false
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (replyInput.current && reply && !replyOpen) replyInput.current.focus()
|
if (replyInput.current && reply && !replyOpen) replyInput.current.focus()
|
||||||
@ -125,7 +113,7 @@ export default forwardRef(function Reply ({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{replyOpen
|
{replyOpen
|
||||||
? <div className={styles.replyButtons} />
|
? <div className='p-3' />
|
||||||
: (
|
: (
|
||||||
<div className={styles.replyButtons}>
|
<div className={styles.replyButtons}>
|
||||||
<div
|
<div
|
||||||
@ -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}`}
|
||||||
>
|
>
|
||||||
|
@ -5,17 +5,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.replyButtons {
|
.replyButtons {
|
||||||
font-size: 75%;
|
font-size: 80%;
|
||||||
color: var(--theme-grey);
|
color: var(--theme-grey);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
|
||||||
padding: .25rem 0 .8rem 0;
|
|
||||||
line-height: 1rem;
|
line-height: 1rem;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.replyButtons > * {
|
||||||
|
padding-top: .4rem;
|
||||||
|
padding-bottom: .8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.skeleton .input {
|
.skeleton .input {
|
||||||
background-color: var(--theme-grey);
|
background-color: var(--theme-grey);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -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) return
|
||||||
}
|
|
||||||
|
|
||||||
// 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'}
|
||||||
|
@ -81,7 +81,7 @@ export default function TerritoryHeader ({ sub }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TerritoryPaymentDue sub={sub} />
|
<TerritoryPaymentDue sub={sub} />
|
||||||
<div className='mb-3'>
|
<div className='mb-2 mt-1'>
|
||||||
<div>
|
<div>
|
||||||
<TerritoryDetails sub={sub}>
|
<TerritoryDetails sub={sub}>
|
||||||
<div className='d-flex my-2 justify-content-end'>
|
<div className='d-flex my-2 justify-content-end'>
|
||||||
|
@ -6,24 +6,22 @@ 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 } = await paySub({
|
||||||
variables
|
variables
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) throw error
|
||||||
throw new Error({ message: error.toString() })
|
|
||||||
}
|
|
||||||
}, [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 +54,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
|
||||||
}}
|
}}
|
||||||
|
@ -15,7 +15,7 @@ import copy from 'clipboard-copy'
|
|||||||
import ZoomableImage, { decodeOriginalUrl } from './image'
|
import ZoomableImage, { decodeOriginalUrl } from './image'
|
||||||
import { IMGPROXY_URL_REGEXP, parseInternalLinks, parseEmbedUrl } from '@/lib/url'
|
import { IMGPROXY_URL_REGEXP, parseInternalLinks, parseEmbedUrl } from '@/lib/url'
|
||||||
import reactStringReplace from 'react-string-replace'
|
import reactStringReplace from 'react-string-replace'
|
||||||
import { rehypeInlineCodeProperty } from '@/lib/md'
|
import { rehypeInlineCodeProperty, rehypeStyler } from '@/lib/md'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@ -24,6 +24,10 @@ import isEqual from 'lodash/isEqual'
|
|||||||
import UserPopover from './user-popover'
|
import UserPopover from './user-popover'
|
||||||
import ItemPopover from './item-popover'
|
import ItemPopover from './item-popover'
|
||||||
|
|
||||||
|
// Explicitely defined start/end tags & which CSS class from text.module.css to apply
|
||||||
|
export const rehypeSuperscript = () => rehypeStyler('<sup>', '</sup>', styles.superscript)
|
||||||
|
export const rehypeSubscript = () => rehypeStyler('<sub>', '</sub>', styles.subscript)
|
||||||
|
|
||||||
export function SearchText ({ text }) {
|
export function SearchText ({ text }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
@ -200,8 +204,10 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (text.startsWith?.('@')) {
|
if (text.startsWith?.('@')) {
|
||||||
|
// user mention might be within a markdown link like this: [@user foo bar](url)
|
||||||
|
const name = text.replace('@', '').split(' ')[0]
|
||||||
return (
|
return (
|
||||||
<UserPopover name={text.replace('@', '')}>
|
<UserPopover name={name}>
|
||||||
<Link
|
<Link
|
||||||
id={props.id}
|
id={props.id}
|
||||||
href={href}
|
href={href}
|
||||||
@ -281,8 +287,24 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||||||
<div className={styles.videoContainer}>
|
<div className={styles.videoContainer}>
|
||||||
<iframe
|
<iframe
|
||||||
title='Rumble Video'
|
title='Rumble Video'
|
||||||
allowFullScreen=''
|
allowFullScreen
|
||||||
src={meta?.href}
|
src={meta?.href}
|
||||||
|
sandbox='allow-scripts'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'peertube') {
|
||||||
|
return (
|
||||||
|
<div style={videoWrapperStyles}>
|
||||||
|
<div className={styles.videoContainer}>
|
||||||
|
<iframe
|
||||||
|
title='PeerTube Video'
|
||||||
|
allowFullScreen
|
||||||
|
src={meta?.href}
|
||||||
|
sandbox='allow-scripts'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -295,7 +317,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||||||
img: Img
|
img: Img
|
||||||
}}
|
}}
|
||||||
remarkPlugins={[gfm, mention, sub]}
|
remarkPlugins={[gfm, mention, sub]}
|
||||||
rehypePlugins={[rehypeInlineCodeProperty]}
|
rehypePlugins={[rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript]}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
@ -252,3 +252,15 @@ img.fullScreen {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Utility classes used in rehype plugins in md.js */
|
||||||
|
.subscript {
|
||||||
|
vertical-align: sub;
|
||||||
|
font-size: smaller;
|
||||||
|
}
|
||||||
|
|
||||||
|
.superscript {
|
||||||
|
vertical-align: super;
|
||||||
|
font-size: smaller;
|
||||||
|
}
|
@ -139,7 +139,7 @@ export const ToastProvider = ({ children }) => {
|
|||||||
>
|
>
|
||||||
<ToastBody>
|
<ToastBody>
|
||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
<div className='flex-grow-1'>{toast.body}</div>
|
<div className='flex-grow-1 overflow-hidden'>{toast.body}</div>
|
||||||
<Button
|
<Button
|
||||||
variant={null}
|
variant={null}
|
||||||
className='p-0 ps-2'
|
className='p-0 ps-2'
|
||||||
|
@ -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 {
|
||||||
|
@ -46,7 +46,7 @@ export default function TopHeader ({ sub, cat }) {
|
|||||||
initial={{ what, by, when, from: '', to: '' }}
|
initial={{ what, by, when, from: '', to: '' }}
|
||||||
onSubmit={top}
|
onSubmit={top}
|
||||||
>
|
>
|
||||||
<div className='text-muted fw-bold mt-1 mb-3 d-flex align-items-center flex-wrap'>
|
<div className='text-muted fw-bold my-1 d-flex align-items-center flex-wrap'>
|
||||||
<div className='text-muted fw-bold mb-2 d-flex align-items-center'>
|
<div className='text-muted fw-bold mb-2 d-flex align-items-center'>
|
||||||
<Select
|
<Select
|
||||||
groupClassName='me-2 mb-0'
|
groupClassName='me-2 mb-0'
|
||||||
|
@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
.upvoteWrapper {
|
.upvoteWrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-right: .2rem;
|
padding-right: .2rem;
|
||||||
padding-left: .2rem;
|
padding-left: .2rem;
|
||||||
margin-left: -.4rem;
|
margin-left: -.4rem;
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,29 @@ import { gql, useMutation, useQuery, useLazyQuery } from '@apollo/client'
|
|||||||
import { SETTINGS } from '@/fragments/users'
|
import { SETTINGS } from '@/fragments/users'
|
||||||
import { ITEM_FULL_FIELDS, POLL_FIELDS } from '@/fragments/items'
|
import { ITEM_FULL_FIELDS, POLL_FIELDS } from '@/fragments/items'
|
||||||
|
|
||||||
async function discussionToEvent (item) {
|
function itemToContent (item, { includeTitle = true } = {}) {
|
||||||
|
let content = includeTitle ? item.title : ''
|
||||||
|
|
||||||
|
if (item.url) {
|
||||||
|
content += `\n${item.url}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.text) {
|
||||||
|
content += `\n\n${item.text}`
|
||||||
|
}
|
||||||
|
|
||||||
|
content += `\n\noriginally posted at https://stacker.news/items/${item.id}`
|
||||||
|
|
||||||
|
return content.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function discussionToEvent (item) {
|
||||||
const createdAt = Math.floor(Date.now() / 1000)
|
const createdAt = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
created_at: createdAt,
|
created_at: createdAt,
|
||||||
kind: 30023,
|
kind: 30023,
|
||||||
content: item.text,
|
content: itemToContent(item, { includeTitle: false }),
|
||||||
tags: [
|
tags: [
|
||||||
['d', item.id.toString()],
|
['d', item.id.toString()],
|
||||||
['title', item.title],
|
['title', item.title],
|
||||||
@ -21,25 +37,18 @@ async function discussionToEvent (item) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function linkToEvent (item) {
|
function linkToEvent (item) {
|
||||||
const createdAt = Math.floor(Date.now() / 1000)
|
const createdAt = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
let contentField
|
|
||||||
if (item.text) {
|
|
||||||
contentField = `${item.title}\n${item.url}\n\n${item.text}`
|
|
||||||
} else {
|
|
||||||
contentField = `${item.title}\n${item.url}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
created_at: createdAt,
|
created_at: createdAt,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
content: contentField,
|
content: itemToContent(item),
|
||||||
tags: []
|
tags: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollToEvent (item) {
|
function pollToEvent (item) {
|
||||||
const createdAt = Math.floor(Date.now() / 1000)
|
const createdAt = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
const expiresAt = createdAt + 86400
|
const expiresAt = createdAt + 86400
|
||||||
@ -47,20 +56,20 @@ async function pollToEvent (item) {
|
|||||||
return {
|
return {
|
||||||
created_at: createdAt,
|
created_at: createdAt,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
content: item.text,
|
content: itemToContent(item),
|
||||||
tags: [
|
tags: [
|
||||||
['poll', 'single', expiresAt.toString(), item.title, ...item.poll.options.map(op => op?.option.toString())]
|
['poll', 'single', expiresAt.toString(), item.title, ...item.poll.options.map(op => op?.option.toString())]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bountyToEvent (item) {
|
function bountyToEvent (item) {
|
||||||
const createdAt = Math.floor(Date.now() / 1000)
|
const createdAt = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
created_at: createdAt,
|
created_at: createdAt,
|
||||||
kind: 30402,
|
kind: 30402,
|
||||||
content: item.text,
|
content: itemToContent(item),
|
||||||
tags: [
|
tags: [
|
||||||
['d', item.id.toString()],
|
['d', item.id.toString()],
|
||||||
['title', item.title],
|
['title', item.title],
|
||||||
@ -158,16 +167,15 @@ export default function useCrossposter () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const itemType = determineItemType(item)
|
const itemType = determineItemType(item)
|
||||||
|
|
||||||
switch (itemType) {
|
switch (itemType) {
|
||||||
case 'discussion':
|
case 'discussion':
|
||||||
return await discussionToEvent(item)
|
return discussionToEvent(item)
|
||||||
case 'link':
|
case 'link':
|
||||||
return await linkToEvent(item)
|
return linkToEvent(item)
|
||||||
case 'bounty':
|
case 'bounty':
|
||||||
return await bountyToEvent(item)
|
return bountyToEvent(item)
|
||||||
case 'poll':
|
case 'poll':
|
||||||
return await pollToEvent(item)
|
return pollToEvent(item)
|
||||||
default:
|
default:
|
||||||
return crosspostError('Unknown item type')
|
return crosspostError('Unknown item type')
|
||||||
}
|
}
|
||||||
|
116
components/use-item-submit.js
Normal file
116
components/use-item-submit.js
Normal 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) return
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
@ -1,12 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { SSR } from '@/lib/constants'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
export default function useLocalState (storageKey, initialValue = '') {
|
export default function useLocalState (storageKey, initialValue = '') {
|
||||||
const [value, innerSetValue] = useState(initialValue)
|
const [value, innerSetValue] = useState(
|
||||||
|
initialValue ||
|
||||||
useEffect(() => {
|
(SSR ? null : JSON.parse(window.localStorage.getItem(storageKey)))
|
||||||
const value = window.localStorage.getItem(storageKey)
|
)
|
||||||
innerSetValue(JSON.parse(value))
|
|
||||||
}, [storageKey])
|
|
||||||
|
|
||||||
const setValue = useCallback((newValue) => {
|
const setValue = useCallback((newValue) => {
|
||||||
window.localStorage.setItem(storageKey, JSON.stringify(newValue))
|
window.localStorage.setItem(storageKey, JSON.stringify(newValue))
|
||||||
|
170
components/use-paid-mutation.js
Normal file
170
components/use-paid-mutation.js
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { InvoiceCanceledError, InvoiceExpiredError, useQrPayment, useWalletPayment } from './payment'
|
||||||
|
import { GET_PAID_ACTION } from '@/fragments/paidAction'
|
||||||
|
|
||||||
|
/*
|
||||||
|
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 [getPaidAction] = useLazyQuery(GET_PAID_ACTION, {
|
||||||
|
fetchPolicy: 'network-only'
|
||||||
|
})
|
||||||
|
const waitForWalletPayment = useWalletPayment()
|
||||||
|
const waitForQrPayment = useQrPayment()
|
||||||
|
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, waitFor }) => {
|
||||||
|
let walletError
|
||||||
|
const start = Date.now()
|
||||||
|
try {
|
||||||
|
return await waitForWalletPayment(invoice, waitFor)
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
walletError = err
|
||||||
|
}
|
||||||
|
return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor })
|
||||||
|
}, [waitForWalletPayment, 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, update } = { ...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) {
|
||||||
|
// adds payError, escalating to a normal error if the invoice is not canceled or
|
||||||
|
// has an actionError
|
||||||
|
const addPayError = (e, rest) => ({
|
||||||
|
...rest,
|
||||||
|
payError: e,
|
||||||
|
error: e instanceof InvoiceCanceledError && e.actionError ? e : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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 => addPayError(e, r))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// the action is pessimistic
|
||||||
|
try {
|
||||||
|
// wait for the invoice to be paid
|
||||||
|
await waitForPayment(invoice, { persistOnNavigate, waitFor: inv => inv?.actionState === 'PAID' })
|
||||||
|
if (!response.result) {
|
||||||
|
// if the mutation didn't return any data, ie pessimistic, we need to fetch it
|
||||||
|
const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
|
||||||
|
// create new data object
|
||||||
|
data = { [Object.keys(data)[0]]: paidAction }
|
||||||
|
// we need to run update functions on mutations now that we have the data
|
||||||
|
update?.(client.cache, { data })
|
||||||
|
}
|
||||||
|
ourOnCompleted?.(data)
|
||||||
|
onPaid?.(client.cache, { data })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('usePaidMutation: failed to pay invoice', e)
|
||||||
|
onPayError?.(e, client.cache, { data })
|
||||||
|
rest = addPayError(e, rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// fee credits paid for it
|
||||||
|
ourOnCompleted?.(data)
|
||||||
|
onPaid?.(client.cache, { data })
|
||||||
|
}
|
||||||
|
|
||||||
|
setInnerResult({ data, ...rest })
|
||||||
|
return { data, ...rest }
|
||||||
|
}, [mutate, options, waitForPayment, onCompleted, client.cache, getPaidAction, setInnerResult])
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
23
components/wallet-buttonbar.js
Normal file
23
components/wallet-buttonbar.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Button } from 'react-bootstrap'
|
||||||
|
import CancelButton from './cancel-button'
|
||||||
|
import { SubmitButton } from './form'
|
||||||
|
|
||||||
|
export default function WalletButtonBar ({
|
||||||
|
wallet, disable,
|
||||||
|
className, children, onDelete, onCancel, hasCancel = true,
|
||||||
|
createText = 'attach', deleteText = 'detach', editText = 'save'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`mt-3 ${className}`}>
|
||||||
|
<div className='d-flex justify-content-between'>
|
||||||
|
{wallet.isConfigured &&
|
||||||
|
<Button onClick={onDelete} variant='grey-medium'>{deleteText}</Button>}
|
||||||
|
{children}
|
||||||
|
<div className='d-flex align-items-center ms-auto'>
|
||||||
|
{hasCancel && <CancelButton onClick={onCancel} />}
|
||||||
|
<SubmitButton variant='primary' disabled={disable}>{wallet.isConfigured ? editText : createText}</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,14 +1,12 @@
|
|||||||
import { Badge, Button, Card } from 'react-bootstrap'
|
import { Badge, Card } from 'react-bootstrap'
|
||||||
import styles from '@/styles/wallet.module.css'
|
import styles from '@/styles/wallet.module.css'
|
||||||
import Plug from '@/svgs/plug.svg'
|
import Plug from '@/svgs/plug.svg'
|
||||||
import Gear from '@/svgs/settings-5-fill.svg'
|
import Gear from '@/svgs/settings-5-fill.svg'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import CancelButton from './cancel-button'
|
import { Status } from 'wallets'
|
||||||
import { SubmitButton } from './form'
|
|
||||||
import { useWallet, Status } from './wallet'
|
|
||||||
|
|
||||||
export function WalletCard ({ name, title, badges, status }) {
|
export default function WalletCard ({ wallet }) {
|
||||||
const wallet = useWallet(name)
|
const { card: { title, badges } } = wallet
|
||||||
|
|
||||||
let indicator = styles.disabled
|
let indicator = styles.disabled
|
||||||
switch (wallet.status) {
|
switch (wallet.status) {
|
||||||
@ -41,7 +39,7 @@ export function WalletCard ({ name, title, badges, status }) {
|
|||||||
</Badge>)}
|
</Badge>)}
|
||||||
</Card.Subtitle>
|
</Card.Subtitle>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
<Link href={`/settings/wallets/${name}`}>
|
<Link href={`/settings/wallets/${wallet.name}`}>
|
||||||
<Card.Footer className={styles.attach}>
|
<Card.Footer className={styles.attach}>
|
||||||
{wallet.isConfigured
|
{wallet.isConfigured
|
||||||
? <>configure<Gear width={14} height={14} /></>
|
? <>configure<Gear width={14} height={14} /></>
|
||||||
@ -51,23 +49,3 @@ export function WalletCard ({ name, title, badges, status }) {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WalletButtonBar ({
|
|
||||||
wallet, disable,
|
|
||||||
className, children, onDelete, onCancel, hasCancel = true,
|
|
||||||
createText = 'attach', deleteText = 'detach', editText = 'save'
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={`mt-3 ${className}`}>
|
|
||||||
<div className='d-flex justify-content-between'>
|
|
||||||
{wallet.isConfigured &&
|
|
||||||
<Button onClick={onDelete} variant='grey-medium'>{deleteText}</Button>}
|
|
||||||
{children}
|
|
||||||
<div className='d-flex align-items-center ms-auto'>
|
|
||||||
{hasCancel && <CancelButton onClick={onCancel} />}
|
|
||||||
<SubmitButton variant='primary' disabled={disable}>{wallet.isConfigured ? editText : createText}</SubmitButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user