Compare commits

..

170 Commits

Author SHA1 Message Date
ekzyis
5d03e08514 Remove validate.schema as a trap door 2024-07-17 03:32:00 +02:00
ekzyis
6a5713034b Make clear that message belongs to test
* validate.message was used in tandem with validate.test
* it might be confused as the message if the validation for validate.type failed
* now validate.test can be a function or an object of { test, message } shape which matches Yup.test
2024-07-17 03:31:16 +02:00
ekzyis
c8d91bf42d Generate validation schema for CLN 2024-07-17 03:31:16 +02:00
ekzyis
08a5ce1a28 Remove stringTypes 2024-07-17 03:31:16 +02:00
ekzyis
4df0b460c3 Generate validation schema for LnAddr 2024-07-17 02:48:46 +02:00
ekzyis
587bfa34be Generate validation schema for LND 2024-07-17 02:38:04 +02:00
ekzyis
3933a4f460 Generate validation schema for LNC 2024-07-17 01:25:53 +02:00
ekzyis
667cde6042 Rename to torAllowed 2024-07-17 01:03:45 +02:00
ekzyis
6432ea7b44 Generate validation schema for NWC 2024-07-17 00:58:43 +02:00
ekzyis
fb2b34ce67 Generate validation schema for LNbits 2024-07-17 00:58:43 +02:00
ekzyis
9587ff9a52 Fix autowithdrawal error log 2024-07-16 22:55:04 +02:00
ekzyis
538f1e21d6 Fix id access in walletPrioritySort 2024-07-16 22:46:15 +02:00
ekzyis
e25a3dbec0 Fix w.default usage
Since package.json with { "type": "module" } was added, this is no longer needed.
2024-07-16 22:39:24 +02:00
Keyan
128f1f93b8
Merge branch 'master' into wallet-interface 2024-07-16 15:24:02 -05:00
ekzyis
b777fdcddc Fix wallet.server usage
* I removed wallet.server in a previous commit
* the client couldn't determine which wallet was stored on the server since all server specific fields were set in server.js
* walletType and walletField are now set in index.js
* walletType is now used to determine if a wallet is stored on the server

* also included some formatting changes
2024-07-16 22:08:41 +02:00
keyan
3a748b8d38 exclude saloon from rewards 2024-07-16 13:54:21 -05:00
keyan
98efe763a0 remove done referral todos 2024-07-16 13:44:58 -05:00
keyan
c6581b2cb1 allow db connection limit and timeout configuration 2024-07-16 13:42:09 -05:00
ekzyis
bbcfc2fada Fix worker import of wallets/server 2024-07-16 17:44:21 +02:00
ekzyis
5b2e835722 Separate client and server imports by files
* wallets now consist of an index.js, a client.js and a server.js file
* client.js is imported on the client and contains the client portion
* server.js is imported on the server and contains the server porition
* both reexport index.js so everything in index.js can be shared by client and server

* every wallet contains a client.js file since they are all imported on the client to show the cards

* client.js of every wallet is reexported as an array in wallets/client.js
* server.js of every wallet is reexported as an array in wallets/server.js

FIXME: for some reason, worker does not properly import the default export of wallets/server.js
2024-07-16 15:46:44 +02:00
ekzyis
259ebef971 Fix generateMutation
* remove resolverName property from wallet defs
* move function into lib/wallet
* use function in generateMutation on client to fix wrongly generated mutation
2024-07-16 14:18:57 +02:00
ekzyis
7851366cd5 Put wallets into own folder 2024-07-16 07:54:27 +02:00
ekzyis
cba76444dd Move wallets into top level directory wallet/ 2024-07-16 06:09:27 +02:00
ekzyis
f01ce79afa Generate resolver name from walletField 2024-07-16 04:08:13 +02:00
ekzyis
03ca84629b Remove React dependency from wallet definitions 2024-07-15 16:23:24 +02:00
ekzyis
7749c14d3b Remove 'tor or clearnet' hint for LN addresses 2024-07-15 14:46:48 +02:00
ekzyis
ee1574cf45 Fix leaking relay connections 2024-07-15 13:56:21 +02:00
ekzyis
6ac675429c Merge branch 'master' into wallet-interface 2024-07-15 13:24:38 +02:00
keyan
dd4806c1a3 avoid float in prisma template param 2024-07-14 16:53:06 -05:00
keyan
e045c46811 fix Item.root resolver for anon 2024-07-14 15:53:40 -05:00
keyan
c767e106a0 merge master 2024-07-12 18:24:31 -05:00
keyan
e1d6632445 fix action tooltip container 2024-07-12 17:34:46 -05:00
keyan
da65191cd8 refine comment padding 2024-07-12 15:18:13 -05:00
keyan
3f9d509a52 more notification refinement 2024-07-12 10:55:24 -05:00
keyan
bc2cb29c41 improve notification header styling 2024-07-12 10:38:47 -05:00
keyan
cb6b85345c attempt fix for popovers 2024-07-12 10:15:57 -05:00
keyan
35cf792ff8 refine notifications some 2024-07-12 09:51:03 -05:00
Keyan
b31a8dbf2c
Update awards.csv 2024-07-11 18:23:33 -05:00
keyan
2e90f02997 more popover leave events 2024-07-11 17:48:57 -05:00
keyan
0aea695d8a higher contrast outline for new notifications 2024-07-11 17:32:28 -05:00
keyan
729aab12eb better component reuse in notifications 2024-07-11 17:29:05 -05:00
keyan
9ac31095c8 docker-compose isn't a thing anymore 2024-07-11 17:28:13 -05:00
keyan
07042c57ca improve UX of notifications 2024-07-11 16:59:07 -05:00
keyan
28c4fa160c fix item spacing 2024-07-11 16:58:55 -05:00
keyan
501ac9f220 add action state indices 2024-07-11 12:49:23 -05:00
keyan
6c6d2dab18 fix typo 2024-07-11 11:55:19 -05:00
keyan
64eb22cc5e new rewards banner 2024-07-10 19:59:05 -05:00
keyan
ce45574bce fix #1261 2024-07-10 19:39:53 -05:00
Keyan
c6554d3ca7
Referral Rewards (#1262)
* referral rewards

* make referral notifications consistent

* remove plpgsql from earn job

* remove dead code

* remove debug logging
2024-07-10 19:23:05 -05:00
keyan
6cf16d3da7 don't toast on invoice cancellation 2024-07-09 13:10:41 -05:00
keyan
94d9d9513c hide overflow of toasts 2024-07-09 11:46:38 -05:00
keyan
f05b6fab84 add wallets profile to allow exclusion on attached wallet containers 2024-07-09 11:37:55 -05:00
keyan
e0f91ace41 prevent lnc-web's wasm loading side effects from breaking everything 2024-07-08 16:10:19 -05:00
keyan
0312012089 make sure stackedMsats is updated for forwardees 2024-07-08 09:35:29 -05:00
ekzyis
02472bb81f
Fix missing stackedMsats update + wrong sybil fee (#1256)
* Fix 'stackedMsats' not updated

* Fix 1% fee instead of 10%
2024-07-08 09:07:35 -05:00
ekzyis
6e6af40eb9 Toast priority save errors 2024-07-08 13:20:03 +02:00
ekzyis
05c0f8a66e Remove console.log 2024-07-08 13:14:30 +02:00
ekzyis
80756f23a4 Remove TODOs
TODO in components/wallet-logger.js was handled.
I don't see a need for the TODO in lib/wallet.js anymore. This function will only be called with the wallet of type LIGHTNING_ADDRESS anyway.
2024-07-08 13:04:03 +02:00
ekzyis
24bdf0a099 Add example wallet def 2024-07-08 12:58:58 +02:00
ekzyis
d9205b6d30 Add link to lnbits.com 2024-07-08 12:56:43 +02:00
ekzyis
7402885998 Use common sort 2024-07-08 11:34:05 +02:00
ekzyis
1a60f13d72 Fix order if wallet with no priority exists 2024-07-08 11:06:46 +02:00
ekzyis
920478a72c Update LNC code
* remove LNC FIXMEs

Mhh, I guess the TURN server was down or something? It now magically works. Or maybe it only works once per mnemonic?

* also removed the lnc.lnd.lightning.getInfo() call since we don't ask and need permission for this RPC for payments.

* setting a password does not work though. It fails with 'The password provided is not valid' which is triggered at https://github.com/lightninglabs/lnc-web/blob/main/lib/util/credentialStore.ts#L81.
2024-07-08 10:59:04 +02:00
ekzyis
9af8e63355 Fix error per invalid bip39 word 2024-07-08 08:26:51 +02:00
ekzyis
8a36bffb85 Fix autowithdraw priority order 2024-07-08 08:07:14 +02:00
ekzyis
8ea4d0c8a7 Fix duplicate CLN error 2024-07-08 07:59:28 +02:00
ekzyis
2051dd0e88 Use touches instead of dnd on mobile
Browsers don't support drag events for touch devices.

To have a consistent implementation for desktop and mobile, we would need to use mousedown/touchstart, mouseup/touchend and mousemove/touchmove.

For now, this commit makes changing the order possible on touch devices with simple touches.
2024-07-08 07:33:10 +02:00
ekzyis
5d678ced23 Fix draggable false on first page load due to SSR 2024-07-08 06:54:27 +02:00
ekzyis
459478036f Fix priority ignored when fetching enabled wallet 2024-07-08 05:49:54 +02:00
ekzyis
a69bca0f05 Use inject function for resolvers and typeDefs 2024-07-07 20:04:33 +02:00
ekzyis
85cfda330b Remove Wallet in lib/constants 2024-07-07 18:35:57 +02:00
ekzyis
85464f93b9 Detach wallets and delete logs on logout 2024-07-07 18:35:57 +02:00
ekzyis
dddbb53792 Add CLN autowithdrawal 2024-07-07 18:35:57 +02:00
ekzyis
ebe741dc92 Add missing hints 2024-07-07 18:35:57 +02:00
ekzyis
6bee659f2f Fix autowithdraw loop 2024-07-07 18:35:57 +02:00
ekzyis
bd0e4d906c Fix draggable 2024-07-07 18:35:57 +02:00
ekzyis
7528e5c2b6 Add optional wallet short name for logging 2024-07-07 18:35:57 +02:00
ekzyis
1ce09051b1 Add autowithdrawal to lightning address 2024-07-07 18:35:56 +02:00
ekzyis
8dac53d7d5 Fix wallet security banner shown for server wallets 2024-07-07 18:31:41 +02:00
ekzyis
cd074a47b7 Fix success autowithdrawal log 2024-07-07 18:31:41 +02:00
ekzyis
12bedae01a Use wallet.createInvoice for autowithdrawals 2024-07-07 18:31:41 +02:00
ekzyis
b569c8faa0 Fix import inconsistency between app and worker 2024-07-07 18:31:41 +02:00
ekzyis
ba00c3d9fa Generate wallet resolver from fields 2024-07-07 18:31:41 +02:00
ekzyis
00f78daadc Generate wallet mutation from fields 2024-07-07 18:31:41 +02:00
ekzyis
0a0085fe82 Remove unnecessary WALLETS_QUERY 2024-07-07 18:31:41 +02:00
ekzyis
48ead97615 Run lnbits url.replace in validate and sendPayment 2024-07-07 18:31:41 +02:00
ekzyis
6463e6eec8 Split arguments into [value,] config, context 2024-07-07 18:31:41 +02:00
ekzyis
0ebe097a70 Fix noisy changes in lib/validate
I moved the schema for lnbits, nwc and lnc out of lib/validate only to put them back in there later.

This commit should make the changeset cleaner by removing noise.
2024-07-07 18:31:41 +02:00
ekzyis
850c534c91 Fix typo 2024-07-07 18:31:41 +02:00
ekzyis
83fd39b035 Fix onCanceled missing 2024-07-07 18:31:41 +02:00
ekzyis
9bbf2056e9 Save dedicated enabled flag for server wallets
* wallet table now contains boolean column 'enabled'
* 'priority' is now a number everywhere
* use consistent order between how autowithdrawals are attempted and server wallets cards
2024-07-07 18:31:41 +02:00
ekzyis
8acf74c787 Fix autowithdrawSettings not applied
Form requires config in flat format but mutation requires autowithdraw settings in a separate 'settings' field.

I have decided that config will be in flat form format. It will be transformed into mutation format during save.
2024-07-07 18:31:41 +02:00
ekzyis
55928ac252 Save order as priority 2024-07-07 18:31:41 +02:00
ekzyis
c270805649 Use dynamic import for WalletCard
This fixes a lot of issues with hydration
2024-07-07 18:31:41 +02:00
ekzyis
eb2f4b980f Implement drag & drop w/o persistence 2024-07-07 18:31:41 +02:00
ekzyis
b96757b366 Move all validation schema into lib/validate 2024-07-07 18:31:41 +02:00
ekzyis
39d8928772 Disable checkbox if not configured yet 2024-07-07 18:31:41 +02:00
ekzyis
da6d262e0a Also enable server wallets on create 2024-07-07 18:31:41 +02:00
ekzyis
d20e258649 Consistent logs between local and server wallets
* 'wallet attached' on create
* 'wallet updated' on config updates
* 'wallet enabled' and 'wallet disabled' if checkbox changed
* 'wallet detached' on delete
2024-07-07 18:31:41 +02:00
ekzyis
d60e26bfdf Fix wallet logs not updated after server delete 2024-07-07 18:31:41 +02:00
ekzyis
9509833b88 Also use 'enabled' for server wallets 2024-07-07 18:31:41 +02:00
ekzyis
645ff78365 Fix server config not updated after save or detach 2024-07-07 18:31:41 +02:00
ekzyis
c18263dc73 Fix another hydration error 2024-07-07 18:31:41 +02:00
ekzyis
d8e82ddea5 Only include local/server config if required 2024-07-07 18:31:41 +02:00
ekzyis
e091377d94 Fix TypeError in isConfigured if no enabled wallet found 2024-07-07 18:31:41 +02:00
ekzyis
5b561e22a9 Fix wallet logs refetch
onError does not exist on client.mutate
2024-07-07 18:31:41 +02:00
ekzyis
4bf9954c4e Fix delete wallet logs on server 2024-07-07 18:31:41 +02:00
ekzyis
3b0605a691 Fix isConfigured 2024-07-07 18:31:41 +02:00
ekzyis
1f98a1a891 Fix usage of conditional hooks in useConfig 2024-07-07 18:31:41 +02:00
ekzyis
377ac04c85 Use same error format in toast and wallet log 2024-07-07 18:31:41 +02:00
ekzyis
9228328d3b Remove FIXMEs
Rebase on master seemed to have fixed these, weird
2024-07-07 18:31:41 +02:00
ekzyis
2aa0c9bc99 Fix confusing UX around enabled 2024-07-07 18:31:41 +02:00
ekzyis
d7c81cfa9f Fix sendPayment called with empty config
* removed useEffect such that config is available on first render
* fix hydration error using dynamic import without SSR
2024-07-07 18:31:41 +02:00
ekzyis
4a16cc17aa Fix TypeError 2024-07-07 18:31:41 +02:00
ekzyis
4082a45618 wip: Add LND autowithdrawals
* receiving wallets need to export 'server' object field
* don't print macaroon error stack
* fix missing wallet logs order update
* mark autowithdrawl settings as required
* fix server wallet logs deletion
* remove canPay and canReceive since it was confusing where it is available

TODO

* also use numeric priority for sending wallets to be consistent with how status for receiving wallets is determined
* define createInvoice function in wallet definition
* consistent wallet logs: sending wallets use 'wallet attached'+'wallet enabled/disabled' whereas receiving wallets use 'wallet created/updated'
* see FIXMEs
2024-07-07 18:31:41 +02:00
ekzyis
ae0335d537 Don't require destructuring to pass props to input 2024-07-07 18:31:41 +02:00
ekzyis
91978171ed Remove logger.error since already handled in useWallet 2024-07-07 18:31:41 +02:00
ekzyis
dae69ec4b3 Add FIXMEs for LNC
I can't get LNC to connect. It just hangs forever on lnc.connect(). See FIXMEs.
2024-07-07 18:31:40 +02:00
ekzyis
eda7fd6b46 Fix position of log start marker 2024-07-07 18:31:40 +02:00
ekzyis
fd08356d37 Remove follow and show recent logs first 2024-07-07 18:31:40 +02:00
ekzyis
61be80446d Revert "Fix 20s page load for /settings/wallets.json?nodata=true"
This reverts commit deb476b3a966569fefcfdf4082d6b64f90fbd0a2.

Not using the dynamic import for LNC fixed the slow page load with ?nodata=true.
2024-07-07 18:31:40 +02:00
ekzyis
6059e8f691 Use normal imports 2024-07-07 18:31:40 +02:00
ekzyis
1bae891594 Fix extremely slow page load for LNC import
I noticed that the combination of

```
import { Form, PasswordInput, SubmitButton } from '@/components/form'
```

in components/wallet/lnc.js and the dynamic import via `await import` in components/wallet/index.js caused extremely slow page loads.
2024-07-07 18:31:40 +02:00
ekzyis
276e734a7a Fix 20s page load for /settings/wallets.json?nodata=true
For some reason, if nodata is passed (which is the case if going back), the page takes 20s to load.
2024-07-07 18:31:40 +02:00
ekzyis
7b6602e386 wip: Add LNC 2024-07-07 18:31:40 +02:00
ekzyis
8e2dd45e23 Support help, optional, hint in wallet fields 2024-07-07 18:31:40 +02:00
ekzyis
7639390a16 Pass config with spread operator 2024-07-07 18:31:40 +02:00
ekzyis
29646eb956 Use INFO level for 'wallet disabled' message 2024-07-07 18:31:40 +02:00
ekzyis
dd47f2c02b Run validation during save 2024-07-07 18:31:40 +02:00
ekzyis
a5ea53dc39 Fix enableWallet
* wrong storage key was used
* broke if wallets with no configs existed
2024-07-07 18:31:40 +02:00
ekzyis
399c62a7e3 Fix unused isDefault saved in config 2024-07-07 18:31:40 +02:00
ekzyis
034cb4e8b2 Add NWC wallet 2024-07-07 18:31:40 +02:00
ekzyis
b8b0a4f985 Add schema to wallet def 2024-07-07 18:31:40 +02:00
ekzyis
0957cb5b83 Add logging to attach & detach 2024-07-07 18:31:40 +02:00
ekzyis
71c753810c Don't pass logger to sendPayment 2024-07-07 18:31:40 +02:00
ekzyis
0de82db78a Enable wallet if just configured 2024-07-07 18:31:40 +02:00
ekzyis
1a2be99027 Set canPay, canReceive in useWallet 2024-07-07 18:31:40 +02:00
ekzyis
6ac8785c51 Update wallet logging + other stuff
* add canPay and canSend to wallet definition
* rename 'default payment method' to 'enabled' and add enable + disable method
2024-07-07 18:31:40 +02:00
ekzyis
a1b343ac89 Fix import error 2024-07-07 18:31:40 +02:00
ekzyis
5f047cbfc9 wip: Use uniform interface for wallets 2024-07-07 18:31:40 +02:00
keyan
3710840167 upgrade prisma for https://github.com/prisma/prisma/issues/16611 fix 2024-07-07 11:14:12 -05:00
Keyan
3bada4b5da
new referral scheme (#1255)
* capture/store data for new referral scheme

* simplify signup/forever referral rules

* no self-referrals and other fixes

* better post/comment distinction and support /items/1/related
2024-07-07 11:12:02 -05:00
keyan
fc781047d5 fix autowithdraw flag for lightning address 2024-07-06 12:56:20 -05:00
keyan
9213e3ad1a fix settleHodlInvoice timing out paid action transition 2024-07-06 11:37:32 -05:00
keyan
bcdbf9cede print imgproxy dimensions error 2024-07-04 17:54:54 -05:00
Keyan
79f0df17b2
improve pessimistic paid actions by letting the server perform actions and settle invoice on HELD (#1253)
* get rid of hash and hmac based pessimism

* fix readme
2024-07-04 12:30:42 -05:00
ekzyis
7c294478fb
Nostr crossposting backlink + content fix (#1251)
* Add backlink in nostr events

* Remove unnecessary async

* Use itemToContent function

* Fix duplicate title in discussion event
2024-07-03 10:11:24 -05:00
keyan
1a3785a865 only assume insufficient funds in paid action if the error says so 2024-07-03 09:10:04 -05:00
ekzyis
2dd96f4b83
Fix item mention of own items (#1250) 2024-07-02 14:22:58 -05:00
keyan
9145f290dc fix lastCommentAt denorm 2024-07-02 09:26:40 -05:00
keyan
63e60fe2bc subtle change to usePaidMutation callback order 2024-07-01 17:04:10 -05:00
keyan
0aa5ba4955 move invoice creation outside of interactive tx 2024-07-01 17:03:25 -05:00
keyan
6e8d7ef1b8 allow slog logs to be disabled/configured 2024-07-01 16:48:54 -05:00
ekzyis
e57c930f0c
Fix push notifications (#1249)
* Fix wrong author in reply push notification

* Fix duplicate mentions push notifications on save

* Fix item mention push notification argument

* Fix zap push notification using stale msats
2024-07-01 15:51:59 -05:00
keyan
6e1d67b3c0 fix optimistic responses of bounties and poll votes 2024-07-01 14:58:53 -05:00
keyan
1e9d1ce66c fix zap optimistic response 2024-07-01 14:56:17 -05:00
Keyan
ca11ac9fb8
backend payment optimism (#1195)
* wip backend optimism

* another inch

* make action state transitions only happen once

* another inch

* almost ready for testing

* use interactive txs

* another inch

* ready for basic testing

* lint fix

* inches

* wip item update

* get item update to work

* donate and downzap

* inchy inch

* fix territory paid actions

* wip usePaidMutation

* usePaidMutation error handling

* PENDING_HELD and HELD transitions, gql paidAction return types

* mostly working pessimism

* make sure invoice field is present in optimisticResponse

* inches

* show optimistic values to current me

* first pass at notifications and payment status reporting

* fix migration to have withdrawal hash

* reverse optimism on payment failure

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

This reverts commit 93713b33df9bc3701dc5a692b86a04ff64e8cfb1.

* add onCompleted to usePaidMutation

* onPaid and onPayError for new comments

* use 'IS DISTINCT FROM' for NULL invoiceActionState columns

* make usePaidMutation easier to read

* enhance invoice qr

* prevent actions on unpaid items

* allow navigation to action's invoice

* retry create item

* start edit window after item is paid for

* fix ux of retries from notifications

* refine retries

* fix optimistic downzaps

* remember item updates can't be retried

* store reference to action item in invoice

* remove invoice modal layout shift

* fix destructuring

* fix zap undos

* make sure ItemAct is paid in aggregate queries

* dont toast on long press zap undo

* fix delete and remindme bots

* optimistic poll votes with retries

* fix retry notifications and invoice item context

* fix pessimisitic typo

* item mentions and mention notifications

* dont show payment retry on item popover

* make bios work

* refactor paidAction transitions

* remove stray console.log

* restore docker compose nwc settings

* add new todos

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

* fix post edit threshold

* make bounty payments work

* make job posting work

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

* dont use dynamic imports for paid action modules

* inline comment denormalization

* create item starts with median votes

* fix potential of serialization anomalies in zaps

* dont trigger notification indicator on successful paid action invoices

* ignore invoiceId on territory actions and add optimistic concurrency control

* begin docs for paid actions

* better error toasts and fix apollo cache warnings

* small documentation enhancements

* improve paid action docs

* optimistic concurrency control for territory updates

* use satsToMsats and msatsToSats helpers

* explictly type raw query template parameters

* improve consistency of nested relation names

* complete paid action docs

* useEffect for canEdit on payment

* make sure invoiceId is provided when required

* don't return null when expecting array

* remove buy credits

* move verifyPayment to paidAction

* fix comments invoicePaidAt time zone

* close nwc connections once

* grouped logs for paid actions

* stop invoiceWaitUntilPaid if not attempting to pay

* allow actionState to transition directly from HELD to PAID

* make paid mutation wait until pessimistic are fully paid

* change button text when form submits/pays

* pulsing form submit button

* ignore me in notification indicator for territory subscription

* filter unpaid items from more queries

* fix donation stike timing

* fix pending poll vote

* fix recent item notifcation padding

* no default form submitting button text

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

* fix territory autorenew with fee credits

* reorg readme

* allow jobs to be editted forever

* fix image uploads

* more filter fixes for aggregate views

* finalize paid action invoice expirations

* remove unnecessary async

* keep clientside cache normal/consistent

* add more detail to paid action doc

* improve paid action table

* remove actionType guard

* fix top territories

* typo api/paidAction/README.md

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

* typo components/use-paid-mutation.js

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

* Apply suggestions from code review

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

* encorporate ek feeback

* more ek suggestions

* fix 'cost to post' hover on items

* Apply suggestions from code review

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

---------

Co-authored-by: ekzyis <ek@stacker.news>
2024-07-01 12:02:29 -05:00
ekzyis
30e29f709d
Add missing CANCELLED status (#1248) 2024-07-01 08:43:13 -05:00
keyan
fb52d5314d fix missing subname on this day in sn 2024-06-30 11:45:06 -05:00
keyan
9e44baa7f5 make a this day in sn generator page 2024-06-29 18:06:02 -05:00
keyan
658fe73920 upgrade compatibility for #1195 2024-06-29 10:35:51 -05:00
keyan
4fc832c7ed don't import css into /lib 2024-06-25 17:18:21 -05:00
Keyan
1e3d37d6b0
Update awards.csv 2024-06-25 16:36:20 -05:00
nichro
a95402e3be
Added support for <sub> <sup> in markdown (#1215)
* Add support for sub and superscript in markdown

* Removed empty line as per lint

* renamed schema to rehypeSanitizeSchema to make it less generic

* Linting fixes

* Update components/text.js

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

* Reverting changes: remove rehype-raw&sanitize, clean up

* Draft iteration of rehypeStyler plugin

* rehypeStyler visiting element nodes properly to catch tag-text-tag patterns

* Refreshed package-lock

---------

Co-authored-by: ekzyis <ek@stacker.news>
2024-06-25 14:23:18 -05:00
ekzyis
ddaec36617
Fix litd healthcheck (#1246) 2024-06-24 20:28:42 -05:00
ekzyis
3f6581f119
Fix user popover if mention inside link (#1245) 2024-06-23 12:15:26 -05:00
ekzyis
923b21610f
Fix item mention ref pattern (#1244) 2024-06-23 12:14:11 -05:00
keyan
87e198cc04 add this day in stacker news to newsletter 2024-06-22 15:23:30 -05:00
Keyan
a55e865222
Update awards.csv 2024-06-22 15:18:37 -05:00
Tom
4fe920d12b
Handle Peertube Embeds (#1223)
* Handle peertube embeds

* Permit full screen for Rumble and PeerTube

* Use sandbox='allow-scripts' for iframes

* Restore frame-src domains

* Use endsWith

---------

Co-authored-by: ekzyis <ek@stacker.news>
2024-06-20 11:28:25 -05:00
187 changed files with 7961 additions and 3554 deletions

View File

@ -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

View File

@ -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
View File

@ -20,7 +20,6 @@ node_modules/
.DS_Store .DS_Store
*.pem *.pem
/*.sql /*.sql
lnbits/
# debug # debug
npm-debug.log* npm-debug.log*

View File

@ -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

View File

@ -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
View File

@ -0,0 +1,204 @@
# Paid Actions
Paid actions are actions that require payments to perform. Given that we support several payment flows, some of which require more than one round of communication either with LND or the client, and several paid actions, we have this plugin-like interface to easily add new paid actions.
## Payment Flows
There are three payment flows:
### Fee credits
The stacker has enough fee credits to pay for the action. This is the simplest flow and is similar to a normal request.
### Optimistic
The optimistic flow is useful for actions that require immediate feedback to the client, but don't require the action to be immediately visible to everyone else.
For paid actions that support it, if the stacker doesn't have enough fee credits, we store the action in a `PENDING` state on the server, which is visible only to the stacker, then return a payment request to the client. The client then pays the invoice however and whenever they wish, and the server monitors payment progress. If the payment succeeds, the action is executed fully becoming visible to everyone and is marked as `PAID`. Otherwise, the action is marked as `FAILED`, the client is notified the payment failed and the payment can be retried.
<details>
<summary>Internals</summary>
Internally, optimistic flows make use of a state machine that's transitioned by the invoice payment progress. All optimistic actions start in a `PENDING` state and have the following transitions:
- `PENDING` -> `PAID`: when the invoice is paid
- `PENDING` -> `FAILED`: when the invoice expires or is cancelled
- `FAILED` -> `RETRYING`: when the invoice for the action is replaced with a new invoice
</details>
### Pessimistic
For paid actions that don't support optimistic actions (or when the stacker is `@anon`), if the client doesn't have enough fee credits, we return a payment request to the client without 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.

View File

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

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

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

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

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

282
api/paidAction/index.js Normal file
View 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
}

View File

@ -0,0 +1,242 @@
import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, USER_ID } from '@/lib/constants'
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers } from '@/lib/webPush'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { satsToMsats } from '@/lib/format'
export const anonable = true
export const supportsPessimism = true
export const supportsOptimism = true
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, 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'}`
}

View 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'}`
}

View 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
}
})
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

155
api/paidAction/zap.js Normal file
View 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}`
}

View File

@ -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 }) => {

View File

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

View File

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

View File

@ -1,6 +1,5 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { ensureProtocol, parseInternalLinks, removeTracking, stripTrailingSlash } from '@/lib/url' import { ensureProtocol, removeTracking, stripTrailingSlash } from '@/lib/url'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import { ruleSet as publicationDateRuleSet } from '@/lib/timedate-scraper' import { ruleSet as publicationDateRuleSet } from '@/lib/timedate-scraper'
@ -8,19 +7,19 @@ import domino from 'domino'
import { import {
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD, ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST, USER_ID, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_USER_IDS ITEM_ALLOW_EDITS, GLOBAL_SEED, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_USER_IDS
} from '@/lib/constants' } from '@/lib/constants'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts' import { parse } from 'tldts'
import uu from 'url-unshort' import uu from 'url-unshort'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate' import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate'
import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention, notifyItemMention } from '@/lib/webPush' import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand, getReminderCommand, hasReminderCommand } from '@/lib/item'
import { datePivot, whenRange } from '@/lib/time' import { datePivot, whenRange } from '@/lib/time'
import { imageFeesInfo, uploadIdsFromText } from './image' import { uploadIdsFromText } from './image'
import assertGofacYourself from './ofac' import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
import performPaidAction from '../paidAction'
function commentsOrderByClause (me, models, sort) { function commentsOrderByClause (me, models, sort) {
if (sort === 'recent') { if (sort === 'recent') {
@ -44,13 +43,15 @@ function commentsOrderByClause (me, models, sort) {
async function comments (me, models, id, sort) { async function comments (me, models, id, sort) {
const orderBy = commentsOrderByClause(me, models, sort) const orderBy = commentsOrderByClause(me, models, sort)
const filter = '' // empty filter as we filter clientside now
if (me) { if (me) {
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id}) `
const [{ item_comments_zaprank_with_me: comments }] = await models.$queryRawUnsafe( const [{ item_comments_zaprank_with_me: comments }] = await models.$queryRawUnsafe(
'SELECT item_comments_zaprank_with_me($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5, $6)', Number(id), GLOBAL_SEED, Number(me.id), COMMENT_DEPTH_LIMIT, filter, orderBy) 'SELECT item_comments_zaprank_with_me($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5, $6)',
Number(id), GLOBAL_SEED, Number(me.id), COMMENT_DEPTH_LIMIT, filter, orderBy)
return comments return comments
} }
const filter = ' AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = \'PAID\') '
const [{ item_comments: comments }] = await models.$queryRawUnsafe( const [{ item_comments: comments }] = await models.$queryRawUnsafe(
'SELECT item_comments($1::INTEGER, $2::INTEGER, $3, $4)', Number(id), COMMENT_DEPTH_LIMIT, filter, orderBy) 'SELECT item_comments($1::INTEGER, $2::INTEGER, $3, $4)', Number(id), COMMENT_DEPTH_LIMIT, filter, orderBy)
return comments return comments
@ -63,7 +64,10 @@ export async function getItem (parent, { id }, { me, models }) {
query: ` query: `
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
WHERE id = $1` ${whereClause(
'"Item".id = $1',
activeOrMine(me)
)}`
}, Number(id)) }, Number(id))
return item return item
} }
@ -115,7 +119,7 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
} else { } else {
return await models.$queryRawUnsafe(` return await models.$queryRawUnsafe(`
SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user, SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user,
COALESCE("ItemAct"."meMsats", 0) as "meMsats", COALESCE("ItemAct"."meMsats", 0) as "meMsats", COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats",
COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark", COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark",
"ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward", "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward",
to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL) to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL)
@ -132,8 +136,10 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id} LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}
LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id} LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id}
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT "itemId", sum("ItemAct".msats) FILTER (WHERE act = 'FEE' OR act = 'TIP') AS "meMsats", SELECT "itemId",
sum("ItemAct".msats) FILTER (WHERE act = 'DONT_LIKE_THIS') AS "meDontLikeMsats" sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND (act = 'FEE' OR act = 'TIP')) AS "meMsats",
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM 'PENDING' AND (act = 'FEE' OR act = 'TIP') AND "Item"."userId" <> ${me.id}) AS "mePendingMsats",
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND act = 'DONT_LIKE_THIS') AS "meDontLikeMsats"
FROM "ItemAct" FROM "ItemAct"
WHERE "ItemAct"."userId" = ${me.id} WHERE "ItemAct"."userId" = ${me.id}
AND "ItemAct"."itemId" = "Item".id AND "ItemAct"."itemId" = "Item".id
@ -180,8 +186,11 @@ function whenClause (when, table) {
return `"${table}".created_at <= $2 and "${table}".created_at >= $1` return `"${table}".created_at <= $2 and "${table}".created_at >= $1`
} }
const activeOrMine = (me) => { export const activeOrMine = (me) => {
return me ? `("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id})` : '"Item".status <> \'STOPPED\'' return me
? [`("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id})`,
`("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id})`]
: ['("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = \'PAID\')', '"Item".status <> \'STOPPED\'']
} }
export const muteClause = me => export const muteClause = me =>
@ -432,6 +441,7 @@ export default {
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL', '"Item"."parentId" IS NULL',
'"Item".bio = false', '"Item".bio = false',
activeOrMine(me),
subClause(sub, 3, 'Item', me, showNsfw), subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me))} muteClause(me))}
ORDER BY rank DESC ORDER BY rank DESC
@ -457,6 +467,7 @@ export default {
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL', '"Item"."parentId" IS NULL',
'"Item".bio = false', '"Item".bio = false',
activeOrMine(me),
await filterClause(me, models, type))} await filterClause(me, models, type))}
ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC
OFFSET $1 OFFSET $1
@ -724,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`

View File

@ -1,6 +1,6 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor'
import { getItem, filterClause, whereClause, muteClause } from './item' import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item'
import { getInvoice, getWithdrawl } from './wallet' import { getInvoice, getWithdrawl } from './wallet'
import { pushSubscriptionSchema, ssValidate } from '@/lib/validate' import { pushSubscriptionSchema, ssValidate } from '@/lib/validate'
import { replyToSubscription } from '@/lib/webPush' import { replyToSubscription } from '@/lib/webPush'
@ -167,7 +167,8 @@ export default {
${whereClause( ${whereClause(
'"Item".created_at < $2', '"Item".created_at < $2',
await filterClause(me, models), await filterClause(me, models),
muteClause(me))} muteClause(me),
activeOrMine(me))}
ORDER BY id ASC, CASE ORDER BY id ASC, CASE
WHEN type = 'Mention' THEN 1 WHEN type = 'Mention' THEN 1
WHEN type = 'Reply' THEN 2 WHEN type = 'Reply' THEN 2
@ -233,6 +234,7 @@ export default {
WHERE "Invoice"."userId" = $1 WHERE "Invoice"."userId" = $1
AND "confirmedAt" IS NOT NULL AND "confirmedAt" IS NOT NULL
AND "isHeld" IS NULL AND "isHeld" IS NULL
AND "actionState" IS NULL
AND created_at < $2 AND created_at < $2
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT})` LIMIT ${LIMIT})`
@ -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 })
}, },

View 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
}
}

View File

@ -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
}
} }
} }
} }

View File

@ -1,9 +1,8 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { amountSchema, ssValidate } from '@/lib/validate' import { amountSchema, ssValidate } from '@/lib/validate'
import serialize from './serial'
import { USER_ID } from '@/lib/constants'
import { getItem } from './item' import { getItem } from './item'
import { topUsers } from './user' import { topUsers } from './user'
import performPaidAction from '../paidAction'
let rewardCache let rewardCache
@ -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: {

View File

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

View File

@ -1,66 +1,10 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import serialize from './serial' import { whenRange } from '@/lib/time'
import { TERRITORY_COST_MONTHLY, TERRITORY_COST_ONCE, TERRITORY_COST_YEARLY, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { datePivot, whenRange } from '@/lib/time'
import { ssValidate, territorySchema } from '@/lib/validate' import { ssValidate, territorySchema } from '@/lib/validate'
import { nextBilling, proratedBillingCost } from '@/lib/territory'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { subViewGroup } from './growth' import { viewGroup } from './growth'
import { notifyTerritoryTransfer } from '@/lib/webPush' import { notifyTerritoryTransfer } from '@/lib/webPush'
export function paySubQueries (sub, models) { import performPaidAction from '../paidAction'
if (sub.billingType === 'ONCE') {
return []
}
// if in active or grace, consider we are billing them from where they are paid up
// and use grandfathered cost
let billedLastAt = sub.billPaidUntil
let billingCost = sub.billingCost
// if the sub is archived, they are paying to reactivate it
if (sub.status === 'STOPPED') {
// get non-grandfathered cost and reset their billing to start now
billedLastAt = new Date()
billingCost = TERRITORY_PERIOD_COST(sub.billingType)
}
const billPaidUntil = nextBilling(billedLastAt, sub.billingType)
const cost = BigInt(billingCost) * BigInt(1000)
return [
models.user.update({
where: {
id: sub.userId
},
data: {
msats: {
decrement: cost
}
}
}),
// update 'em
models.sub.update({
where: {
name: sub.name
},
data: {
billedLastAt,
billPaidUntil,
billingCost,
status: 'ACTIVE'
}
}),
// record 'em
models.subAct.create({
data: {
userId: sub.userId,
subName: sub.name,
msats: cost,
type: 'BILLING'
}
})
]
}
export async function getSub (parent, { name }, { models, me }) { export async function getSub (parent, { name }, { models, me }) {
if (!name) return null if (!name) return null
@ -150,8 +94,8 @@ export default {
COALESCE(floor(sum(msats_spent)/1000), 0) as spent, COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts, COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments COALESCE(sum(comments), 0) as ncomments
FROM ${subViewGroup(range)} ss FROM ${viewGroup(range, 'sub_stats')}
JOIN "Sub" on "Sub".name = ss.sub_name JOIN "Sub" on "Sub".name = u.sub_name
GROUP BY "Sub".name GROUP BY "Sub".name
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
OFFSET $3 OFFSET $3
@ -192,8 +136,8 @@ export default {
COALESCE(floor(sum(msats_spent)/1000), 0) as spent, COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts, COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments COALESCE(sum(comments), 0) as ncomments
FROM ${subViewGroup(range)} ss FROM ${viewGroup(range, 'sub_stats')}
JOIN "Sub" on "Sub".name = ss.sub_name JOIN "Sub" on "Sub".name = u.sub_name
WHERE "Sub"."userId" = $3 WHERE "Sub"."userId" = $3
AND "Sub".status = 'ACTIVE' AND "Sub".status = 'ACTIVE'
GROUP BY "Sub".name GROUP BY "Sub".name
@ -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' } })

View File

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

View File

@ -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 })
} }

View File

@ -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,

View File

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

View File

@ -20,28 +20,38 @@ export default gql`
type ItemActResult { type ItemActResult {
id: ID! id: ID!
sats: Int! sats: Int!
path: String! path: String
act: String! act: String!
} }
type ItemAct {
id: ID!
act: String!
invoice: Invoice
}
extend type Mutation { extend type Mutation {
bookmarkItem(id: ID): Item bookmarkItem(id: ID): Item
pinItem(id: ID): Item pinItem(id: ID): Item
subscribeItem(id: ID): Item subscribeItem(id: ID): Item
deleteItem(id: ID): Item deleteItem(id: ID): Item
upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item! upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput]): 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 {

View File

@ -55,6 +55,12 @@ export default gql`
sortTime: Date! sortTime: Date!
} }
type Invoicification {
id: ID!
invoice: Invoice!
sortTime: Date!
}
type JobChanged { type JobChanged {
id: ID! id: ID!
item: Item! item: Item!
@ -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

View 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!
}
`

View File

@ -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!]!
} }
` `

View File

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

View File

@ -18,15 +18,15 @@ export default gql`
upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!, upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!, postTypes: [String!]!, allowFreebies: Boolean!,
billingType: String!, billingAutoRenew: Boolean!, billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): Sub moderated: Boolean!, 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 {

View File

@ -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)}`

View File

@ -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

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
110 tsmith123 pr #1216 #1213 easy 1 90k stickymarch60@walletofsatoshi.com 2024-06-03
111 tsmith123 pr #1231 #1230 good-first-issue 20k stickymarch60@walletofsatoshi.com 2024-06-13
112 felipebueno issue #1231 #1230 good-first-issue 2k felipebueno@getalby.com 2024-06-13
113 tsmith123 pr #1223 #107 medium 2 10k bonus for our slowness 210k stickymarch60@walletofsatoshi.com 2024-06-22
114 cointastical issue #1223 #107 medium 2 20k cointastical@stacker.news 2024-06-22
115 kravhen pr #1215 #253 medium 2 upgraded to medium 200k nichro@getalby.com 2024-06-28
116 dillon-co pr #1140 #633 hard requested advance 500k bolt11 2024-07-02
117 takitakitanana issue #1257 good-first-issue 2k takitakitanana@stacker.news 2024-07-11

View File

@ -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}

View File

@ -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>

View File

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

View File

@ -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) =>

View File

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

View File

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

View File

@ -25,7 +25,6 @@ import Skull from '@/svgs/death-skull.svg'
import { commentSubTreeRootId } from '@/lib/item' import { commentSubTreeRootId } from '@/lib/item'
import Pin from '@/svgs/pushpin-fill.svg' import Pin from '@/svgs/pushpin-fill.svg'
import LinkToContext from './link-to-context' import LinkToContext from './link-to-context'
import { ItemContextProvider, useItemContext } from './item'
function Parent ({ item, rootText }) { function Parent ({ item, rootText }) {
const root = useRoot() const root = useRoot()
@ -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}>

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,10 +31,8 @@ import Thumb from '@/svgs/thumb-up-fill.svg'
import Eye from '@/svgs/eye-fill.svg' import Eye from '@/svgs/eye-fill.svg'
import EyeClose from '@/svgs/eye-close-line.svg' import EyeClose from '@/svgs/eye-close-line.svg'
import Info from './info' import Info from './info'
import { InvoiceCanceledError, usePayment } from './payment'
import { useMe } from './me' import { useMe } from './me'
import { useClientNotifications } from './client-notifications' import classNames from 'classnames'
import { ActCanceledError } from './item-act'
export class SessionRequiredError extends Error { export class SessionRequiredError extends Error {
constructor () { constructor () {
@ -44,15 +42,18 @@ export class SessionRequiredError extends Error {
} }
export function SubmitButton ({ export function SubmitButton ({
children, variant, value, onClick, disabled, nonDisabledText, ...props children, variant, value, onClick, disabled, appendText, submittingText,
className, ...props
}) { }) {
const formik = useFormikContext() const formik = useFormikContext()
disabled ||= formik.isSubmitting disabled ||= formik.isSubmitting
submittingText ||= children
return ( return (
<Button <Button
variant={variant || 'main'} variant={variant || 'main'}
className={classNames(formik.isSubmitting && styles.pending, className)}
type='submit' type='submit'
disabled={disabled} disabled={disabled}
onClick={value onClick={value
@ -63,7 +64,7 @@ export function SubmitButton ({
: onClick} : onClick}
{...props} {...props}
> >
{children}{!disabled && nonDisabledText && <small> {nonDisabledText}</small>} {formik.isSubmitting ? submittingText : children}{!disabled && appendText && <small> {appendText}</small>}
</Button> </Button>
) )
} }
@ -802,15 +803,12 @@ const StorageKeyPrefixContext = createContext()
export function Form ({ export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately, initial, schema, onSubmit, children, initialError, validateImmediately,
storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef, storageKeyPrefix, validateOnChange = true, requireSession, innerRef,
optimisticUpdate, clientNotification, signal, ...props ...props
}) { }) {
const toaster = useToast() const toaster = useToast()
const initialErrorToasted = useRef(false) const initialErrorToasted = useRef(false)
const feeButton = useFeeButton()
const payment = usePayment()
const me = useMe() const me = useMe()
const { notify, unnotify } = useClientNotifications()
useEffect(() => { useEffect(() => {
if (initialError && !initialErrorToasted.current) { if (initialError && !initialErrorToasted.current) {
@ -836,52 +834,23 @@ export function Form ({
const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => { const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => {
const variables = { amount, ...values } const variables = { amount, ...values }
let revert, cancel, nid
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

View File

@ -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%;
}
}

View File

@ -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}

View File

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

View File

@ -1,37 +1,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>
)
}

View File

@ -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)

View File

@ -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>

View File

@ -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>

View File

@ -1,7 +1,7 @@
import Link from 'next/link' import Link from 'next/link'
import styles from './item.module.css' import styles from './item.module.css'
import UpVote from './upvote' import UpVote from './upvote'
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react' import { useRef } from 'react'
import { USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants' import { USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants'
import Pin from '@/svgs/pushpin-fill.svg' import Pin from '@/svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace' import reactStringReplace from 'react-string-replace'
@ -45,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))

View File

@ -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 {

View File

@ -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

View File

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

View File

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

View File

@ -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 > * {

View File

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

View File

@ -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' }) {

View File

@ -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

View File

@ -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}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useQuery } from '@apollo/client' import { gql, useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment' import Comment, { CommentSkeleton } from './comment'
import Item from './item' import Item from './item'
import ItemJob from './item-job' import ItemJob from './item-job'
@ -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)}

View File

@ -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;
}

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react' import React from 'react'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import styles from './pay-bounty.module.css' import styles from './pay-bounty.module.css'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
@ -6,23 +6,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)
} }
} }

View File

@ -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])

View File

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

View File

@ -1,4 +1,3 @@
import { gql, useMutation } from '@apollo/client'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import { fixedDecimal, numWithUnits } from '@/lib/format' import { fixedDecimal, numWithUnits } from '@/lib/format'
import { timeLeft } from '@/lib/time' import { timeLeft } from '@/lib/time'
@ -6,47 +5,17 @@ import { useMe } from './me'
import styles from './poll.module.css' import styles from './poll.module.css'
import { signIn } from 'next-auth/react' import { signIn } from 'next-auth/react'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
import { POLL_COST } from '@/lib/constants' import { 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
}

View File

@ -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 />}
</> </>
) )

View File

@ -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'

View File

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

View File

@ -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%;

View File

@ -2,7 +2,7 @@ import AccordianItem from './accordian-item'
import { Col, InputGroup, Row, Form as BootstrapForm, Badge } from 'react-bootstrap' import { Col, InputGroup, Row, Form as BootstrapForm, Badge } from 'react-bootstrap'
import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form' import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form'
import FeeButton, { FeeButtonProvider } from './fee-button' import FeeButton, { FeeButtonProvider } from './fee-button'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { gql, useApolloClient, useLazyQuery } from '@apollo/client'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { MAX_TERRITORY_DESC_LENGTH, POST_TYPES, TERRITORY_BILLING_OPTIONS, TERRITORY_PERIOD_COST } from '@/lib/constants' import { MAX_TERRITORY_DESC_LENGTH, POST_TYPES, TERRITORY_BILLING_OPTIONS, TERRITORY_PERIOD_COST } from '@/lib/constants'
@ -12,35 +12,15 @@ import Info from './info'
import { abbrNum } from '@/lib/format' import { abbrNum } from '@/lib/format'
import { purchasedType } from '@/lib/territory' import { purchasedType } from '@/lib/territory'
import { SUB } from '@/fragments/subs' import { SUB } from '@/fragments/subs'
import { usePaidMutation } from './use-paid-mutation'
import { UNARCHIVE_TERRITORY, UPSERT_SUB } from '@/fragments/paidAction'
export default function TerritoryForm ({ sub }) { export default function TerritoryForm ({ sub }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const me = useMe()
const [upsertSub] = useMutation( const [upsertSub] = usePaidMutation(UPSERT_SUB)
gql` const [unarchiveTerritory] = usePaidMutation(UNARCHIVE_TERRITORY)
mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String, $nsfw: Boolean!) {
upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac, nsfw: $nsfw) {
name
}
}`
)
const [unarchiveTerritory] = useMutation(
gql`
mutation unarchiveTerritory($name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String, $nsfw: Boolean!) {
unarchiveTerritory(name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac, nsfw: $nsfw) {
name
}
}`
)
const schema = territorySchema({ client, me, sub }) const schema = territorySchema({ client, me, sub })
@ -56,22 +36,28 @@ export default function TerritoryForm ({ sub }) {
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ ...variables }) => { async ({ ...variables }) => {
const { error } = archived const { error, payError } = archived
? await unarchiveTerritory({ variables }) ? await unarchiveTerritory({ variables })
: await upsertSub({ variables: { oldName: sub?.name, ...variables } }) : await upsertSub({ variables: { oldName: sub?.name, ...variables } })
if (error) { if (error) throw error
throw new Error({ message: error.toString() }) if (payError) 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'}

View File

@ -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'>

View File

@ -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
}} }}

View File

@ -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>

View File

@ -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;
}

View File

@ -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'

View File

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

View File

@ -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'

View File

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

View File

@ -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;
} }

View File

@ -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')
} }

View File

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

View File

@ -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))

View 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()
}
})
}
}

View File

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

View File

@ -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>
)
}

View File

@ -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