Compare commits
No commits in common. "71ce403b0ce9ead5d0f0bdc3f2eeca9a95bbf475" and "eaa15b3b43c370d4189b1c303e3d047de857b1df" have entirely different histories.
71ce403b0c
...
eaa15b3b43
@ -1,4 +0,0 @@
|
|||||||
# Project review guidelines
|
|
||||||
|
|
||||||
- ignore ??? as a placeholder in awards.csv
|
|
||||||
|
|
||||||
@ -32,12 +32,6 @@ SLACK_CHANNEL_ID=
|
|||||||
LNAUTH_URL=http://localhost:3000/api/lnauth
|
LNAUTH_URL=http://localhost:3000/api/lnauth
|
||||||
LNWITH_URL=http://localhost:3000/api/lnwith
|
LNWITH_URL=http://localhost:3000/api/lnwith
|
||||||
|
|
||||||
# auto social poster
|
|
||||||
TWITTER_POSTER_API_KEY=
|
|
||||||
TWITTER_POSTER_API_KEY_SECRET=
|
|
||||||
TWITTER_POSTER_ACCESS_TOKEN=
|
|
||||||
TWITTER_POSTER_ACCESS_TOKEN_SECRET=
|
|
||||||
|
|
||||||
########################################
|
########################################
|
||||||
# SNDEV STUFF WE PRESET #
|
# SNDEV STUFF WE PRESET #
|
||||||
# which you can override in .env.local #
|
# which you can override in .env.local #
|
||||||
@ -63,8 +57,8 @@ INVOICE_HMAC_KEY=a4c1d9c81edb87b79d28809876a18cf72293eadb39f92f3f4f2f1cfbdf907c9
|
|||||||
|
|
||||||
# lnd
|
# lnd
|
||||||
# xxd -p -c0 docker/lnd/sn/regtest/admin.macaroon
|
# xxd -p -c0 docker/lnd/sn/regtest/admin.macaroon
|
||||||
LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a4343416569674177494241674951484f4a69597458736c72592f4931376933574c444354414b42676771686b6a4f50515144416a41344d5238770a485159445651514b45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d52557745775944565151444577777a4e54526d4d574e690a4f546b7a595451774868634e4d6a55774e54497a4d4467784d444d345768634e4d6a59774e7a45344d4467784d444d34576a41344d523877485159445651514b0a45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d52557745775944565151444577777a4e54526d4d574e694f546b7a595451770a5754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e434141524b6d733131422b4e58554e642f54574347492b4b2b5046686b485a31410a5449647732566e766a344f6130784c696c515a4d7779647149586c7a724641485064646a3566697934584c456f43364d4e427636585277706f3448544d4948510a4d41344741315564447745422f775145417749437044415442674e56485355454444414b4267677242674546425163444154415042674e5648524d42416638450a425441444151482f4d42304741315564446751574242526f433554634e58746366464f7458393171364364337a6930327a54423542674e5648524545636a42770a6767777a4e54526d4d574e694f546b7a5954534343577876593246736147397a64494947633235666247356b6768526f62334e304c6d52765932746c636935700a626e526c636d356862494945645735706549494b64573570654842685932746c64494948596e566d59323975626f6345667741414159635141414141414141410a4141414141414141414141414159634572424941427a414b42676771686b6a4f5051514441674e494144424641694541324941462b32436746704a754e5445750a34524f63322f70625870476f4934365573724a65525972614d33414349423974424c6759777a597a2b596b5a4e7a417a7077454c754935564f505959724a6f6b0a7270754d32316b690a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a
|
LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a43434165696741774942416749516139493834682b48653350385a437541525854554d54414b42676771686b6a4f50515144416a41344d5238770a485159445651514b45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d5255774577594456515144457778694e6a41785a5749780a4d474d354f444d774868634e4d6a51774d7a41334d5463774d6a45355768634e4d6a55774e5441794d5463774d6a4535576a41344d523877485159445651514b0a45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d5255774577594456515144457778694e6a41785a5749784d474d354f444d770a5754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e4341415365596a4b62542b4a4a4a37624b6770677a6d6c3278496130364e3174680a2f4f7033533173382b4f4a41387836647849682f326548556b4f7578675a36703549434b496f375a544c356a5963764375793941334b6e466f3448544d4948510a4d41344741315564447745422f775145417749437044415442674e56485355454444414b4267677242674546425163444154415042674e5648524d42416638450a425441444151482f4d4230474131556444675157424252545756796e653752786f747568717354727969466d6a36736c557a423542674e5648524545636a42770a676778694e6a41785a5749784d474d354f444f4343577876593246736147397a64494947633235666247356b6768526f62334e304c6d52765932746c636935700a626e526c636d356862494945645735706549494b64573570654842685932746c64494948596e566d59323975626f6345667741414159635141414141414141410a41414141414141414141414141596345724273414254414b42676771686b6a4f5051514441674e4941444246416945413873616c4a667134476671465557532f0a35347a335461746c6447736673796a4a383035425a5263334f326f434943794e6e3975716976566f5575365935345143624c3966394c575779547a516e61616e0a656977482f51696b0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a
|
||||||
LND_MACAROON=0201036c6e6402f801030a10ba643b9c3fe23f760e1ee63e0196656e1201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620fd0027075985f7073217aa9aaae4d14db0e7ca38f4e572c3b85c81cf6bb580b3
|
LND_MACAROON=0201036c6e6402f801030a106cf4e146abffa5d766befbbf4c73b5a31201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e6572617465120472656164000006202c3bfd55c191e925cbffd73712c9d4b9b4a8440410bde5f8a0a6e33af8b3d876
|
||||||
LND_SOCKET=sn_lnd:10009
|
LND_SOCKET=sn_lnd:10009
|
||||||
|
|
||||||
# nostr (NIP-57 zap receipts)
|
# nostr (NIP-57 zap receipts)
|
||||||
@ -85,7 +79,6 @@ IMGPROXY_READ_TIMEOUT=10
|
|||||||
IMGPROXY_WRITE_TIMEOUT=10
|
IMGPROXY_WRITE_TIMEOUT=10
|
||||||
IMGPROXY_DOWNLOAD_TIMEOUT=9
|
IMGPROXY_DOWNLOAD_TIMEOUT=9
|
||||||
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
|
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
|
||||||
IMGPROXY_ALLOW_ORIGIN=http://localhost:3000
|
|
||||||
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
|
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
|
||||||
# IMGPROXY_ENABLE_DEBUG_HEADERS=true
|
# IMGPROXY_ENABLE_DEBUG_HEADERS=true
|
||||||
|
|
||||||
@ -140,8 +133,8 @@ SN_LND_REST_PORT=8080
|
|||||||
SN_LND_GRPC_PORT=10009
|
SN_LND_GRPC_PORT=10009
|
||||||
SN_LND_P2P_PORT=9735
|
SN_LND_P2P_PORT=9735
|
||||||
# docker exec -u lnd sn_lnd lncli newaddress p2wkh --unused
|
# docker exec -u lnd sn_lnd lncli newaddress p2wkh --unused
|
||||||
SN_LND_PUBKEY=03dc0de8fbe29ef3d26554c615adfd17aaca959403c4e9ecebaac4b83978d86342
|
SN_LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl
|
||||||
SN_LND_ADDR=bcrt1qu6g49vrl8n4ay99hr04wefkfy2e8g0z4nc0sjw
|
SN_LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
|
||||||
# sn_lndk stuff
|
# sn_lndk stuff
|
||||||
SN_LNDK_GRPC_PORT=10012
|
SN_LNDK_GRPC_PORT=10012
|
||||||
|
|
||||||
@ -184,7 +177,6 @@ grpc_proxy=http://tor:7050/
|
|||||||
|
|
||||||
# lnbits
|
# lnbits
|
||||||
LNBITS_WEB_PORT=5001
|
LNBITS_WEB_PORT=5001
|
||||||
LNBITS_WEB_PORT_V1=5002
|
|
||||||
|
|
||||||
# CPU shares for each category
|
# CPU shares for each category
|
||||||
CPU_SHARES_IMPORTANT=1024
|
CPU_SHARES_IMPORTANT=1024
|
||||||
@ -192,8 +184,3 @@ CPU_SHARES_MODERATE=512
|
|||||||
CPU_SHARES_LOW=256
|
CPU_SHARES_LOW=256
|
||||||
|
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# custom domains stuff
|
|
||||||
# local DNS server for custom domain verification, by default it's dnsmasq.
|
|
||||||
# reachable by containers on 172.30.0.2(:53), outside of docker with 0.0.0.0:5353
|
|
||||||
DOMAINS_DNS_SERVER=172.30.0.2
|
|
||||||
3
.github/pull_request_template.md
vendored
3
.github/pull_request_template.md
vendored
@ -10,9 +10,8 @@ _Was anything unclear during your work on this PR? Anything we should definitely
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
**Are your changes backward compatible? Please answer below:**
|
**Are your changes backwards compatible? Please answer below:**
|
||||||
|
|
||||||
_For example, a change is not backward compatible if you removed a GraphQL field or dropped a database column._
|
|
||||||
|
|
||||||
**On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:**
|
**On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:**
|
||||||
|
|
||||||
|
|||||||
29
.github/workflows/extend-awards.yml
vendored
29
.github/workflows/extend-awards.yml
vendored
@ -14,10 +14,7 @@ jobs:
|
|||||||
github.event_name == 'pull_request_target' &&
|
github.event_name == 'pull_request_target' &&
|
||||||
github.event.action == 'closed' &&
|
github.event.action == 'closed' &&
|
||||||
github.event.pull_request.merged == true &&
|
github.event.pull_request.merged == true &&
|
||||||
github.event.pull_request.head.ref != 'extend-awards/patch' &&
|
github.event.pull_request.head.ref != 'extend-awards/patch'
|
||||||
github.event.pull_request.user.login != 'huumn' &&
|
|
||||||
github.event.pull_request.user.login != 'ekzyis' &&
|
|
||||||
github.event.pull_request.user.login != 'Soxasora'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@ -25,36 +22,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.13'
|
python-version: '3.13'
|
||||||
- run: pip install requests
|
- run: pip install requests
|
||||||
- name: Check if branch exists
|
|
||||||
id: check_branch
|
|
||||||
run: |
|
|
||||||
git fetch origin extend-awards/patch || echo "Branch does not exist"
|
|
||||||
if git show-ref --verify --quiet refs/remotes/origin/extend-awards/patch; then
|
|
||||||
echo "exists=true" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "exists=false" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
- name: Checkout to existing branch
|
|
||||||
if: env.exists == 'true'
|
|
||||||
run: |
|
|
||||||
git checkout extend-awards/patch
|
|
||||||
git config user.name 'github-actions[bot]'
|
|
||||||
git config user.email 'github-actions[bot]@users.noreply.github.com'
|
|
||||||
- run: python extend-awards.py
|
- run: python extend-awards.py
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
GITHUB_CONTEXT: ${{ toJson(github) }}
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||||
- name: Commit changes and push to existing branch
|
|
||||||
if: env.exists == 'true'
|
|
||||||
run: |
|
|
||||||
git commit -am "Extending awards.csv" || exit 0
|
|
||||||
git push origin extend-awards/patch
|
|
||||||
- uses: peter-evans/create-pull-request@v7
|
- uses: peter-evans/create-pull-request@v7
|
||||||
if: env.exists == 'false'
|
|
||||||
with:
|
with:
|
||||||
add-paths: awards.csv
|
add-paths: awards.csv
|
||||||
branch: extend-awards/patch
|
branch: extend-awards/patch
|
||||||
commit-message: Extending awards.csv
|
commit-message: Extending awards.csv
|
||||||
title: Extending awards.csv
|
title: Extending awards.csv
|
||||||
body: One or more PR's were merged that solve an issue(s) and awards.csv should be extended. Remember to delete the branch after merging.
|
body: A PR was merged that solves an issue and awards.csv should be extended.
|
||||||
delete-branch: true
|
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@ -53,7 +53,6 @@ docker-compose.*.yml
|
|||||||
*.sql
|
*.sql
|
||||||
!/prisma/migrations/*/*.sql
|
!/prisma/migrations/*/*.sql
|
||||||
!/docker/db/seed.sql
|
!/docker/db/seed.sql
|
||||||
!/docker/db/wallet-seed.sql
|
|
||||||
|
|
||||||
# nostr wallet connect
|
# nostr wallet connect
|
||||||
scripts/nwc-keys.json
|
scripts/nwc-keys.json
|
||||||
@ -67,11 +66,3 @@ docker/lnbits/data
|
|||||||
# nostr link extract
|
# nostr link extract
|
||||||
scripts/nostr-link-extract.config.json
|
scripts/nostr-link-extract.config.json
|
||||||
scripts/nostr-links.db
|
scripts/nostr-links.db
|
||||||
scripts/twitter-link-extract.config.json
|
|
||||||
scripts/twitter-links.db
|
|
||||||
|
|
||||||
# pay-awards
|
|
||||||
scripts/pay-awards.config.json
|
|
||||||
|
|
||||||
# dnsmasq
|
|
||||||
docker/dnsmasq/dnsmasq.d/*
|
|
||||||
53
README.md
53
README.md
@ -87,9 +87,6 @@ COMMANDS
|
|||||||
psql open psql on db
|
psql open psql on db
|
||||||
prisma run prisma commands
|
prisma run prisma commands
|
||||||
|
|
||||||
domains:
|
|
||||||
domains custom domains dev management
|
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
pr fetch and checkout a pr
|
pr fetch and checkout a pr
|
||||||
lint run linters
|
lint run linters
|
||||||
@ -134,36 +131,7 @@ services:
|
|||||||
|
|
||||||
You can read more about [docker compose override files](https://docs.docker.com/compose/multiple-compose-files/merge/).
|
You can read more about [docker compose override files](https://docs.docker.com/compose/multiple-compose-files/merge/).
|
||||||
|
|
||||||
#### Enabling semantic search
|
|
||||||
|
|
||||||
To enable semantic search that uses text embeddings, run `./scripts/nlp-setup`.
|
|
||||||
|
|
||||||
Before running `./scripts/nlp-setup`, ensure the following are true:
|
|
||||||
|
|
||||||
- search is enabled in `COMPOSE_PROFILES`:
|
|
||||||
|
|
||||||
```.env
|
|
||||||
COMPOSE_PROFILES=...,search,...
|
|
||||||
```
|
|
||||||
- The default opensearch index (default name=`item`) is created and done indexing. This should happen the first time you run `./sndev start`, but it may take a few minutes for indexing to complete.
|
|
||||||
|
|
||||||
After `nlp-setup` is done, restart your containers to enable semantic search:
|
|
||||||
|
|
||||||
```
|
|
||||||
> ./sndev restart
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Local DNS via dnsmasq
|
|
||||||
|
|
||||||
To enable dnsmasq:
|
|
||||||
|
|
||||||
- domains should be enabled in `COMPOSE_PROFILES`:
|
|
||||||
|
|
||||||
```.env
|
|
||||||
COMPOSE_PROFILES=...,domains,...
|
|
||||||
```
|
|
||||||
|
|
||||||
To add/remove DNS records you can now use `./sndev domains dns`. More on this [here](#add-or-remove-dns-records-in-local).
|
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
@ -463,25 +431,6 @@ To enable Web Push locally, you will need to set the `VAPID_*` env vars. `VAPID_
|
|||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
## Custom domains
|
|
||||||
|
|
||||||
### Add or remove DNS records in local
|
|
||||||
|
|
||||||
A worker dedicated to verifying custom domains, checks, among other things, if a domain has the correct DNS records and values. This would normally require a real domain and access to its DNS configuration. Therefore we use dnsmasq to have local DNS, make sure you have [enabled it](#local-dns-via-dnsmasq).
|
|
||||||
|
|
||||||
To add a DNS record the syntax is the following:
|
|
||||||
|
|
||||||
`./sndev domains dns add|remove cname|txt <name/domain> <value>`
|
|
||||||
|
|
||||||
For TXT records, you can also use `""` quoted strings on `value`.
|
|
||||||
|
|
||||||
To list all DNS records present in the dnsmasq config: `./sndev domains dns list`
|
|
||||||
|
|
||||||
#### Access a local custom domain added via dnsmasq
|
|
||||||
sndev will use the dnsmasq DNS server by default, but chances are that you might want to access the domain via your browser.
|
|
||||||
|
|
||||||
For every edit on dnsmasq, it will give you the option to either edit the `/etc/hosts` file or use the dnsmasq DNS server which can be reached on `127.0.0.1:5353`. You can avoid getting asked to edit the `/etc/hosts` file by adding the `--no-hosts` parameter.
|
|
||||||
|
|
||||||
# Internals
|
# Internals
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@ -519,7 +468,7 @@ Open a [discussion](http://github.com/stackernews/stacker.news/discussions) or [
|
|||||||
|
|
||||||
# Responsible disclosure
|
# Responsible disclosure
|
||||||
|
|
||||||
If you found a vulnerability, we would greatly appreciate it if you contact us via [security@stacker.news](mailto:security@stacker.news) or open a [security advisory](https://github.com/stackernews/stacker.news/security/advisories/new). Our PGP key can be found [here](https://stacker.news/pgp.txt) (FEE1 E768 E0B3 81F5).
|
If you found a vulnerability, we would greatly appreciate it if you contact us via [security@stacker.news](mailto:security@stacker.news) or open a [security advisory](https://github.com/stackernews/stacker.news/security/advisories/new). Our PGP key can be found [here](https://stacker.news/pgp.txt) (EBAF 75DA 7279 CB48).
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
|||||||
@ -103,7 +103,7 @@ stateDiagram-v2
|
|||||||
| donations | x | | x | x | x | | | x | |
|
| donations | x | | x | x | x | | | x | |
|
||||||
| update posts | x | | x | | x | | x | x | |
|
| update posts | x | | x | | x | | x | x | |
|
||||||
| update comments | x | | x | | x | | x | x | |
|
| update comments | x | | x | | x | | x | x | |
|
||||||
| receive | | | | | x | x | x | | x |
|
| receive | | x | | | x | x | x | | x |
|
||||||
| buy fee credits | | | x | | x | | | x | |
|
| buy fee credits | | | x | | x | | | x | |
|
||||||
| invite gift | x | | | | | | x | x | |
|
| invite gift | x | | | | | | x | x | |
|
||||||
|
|
||||||
@ -205,7 +205,7 @@ The ONLY exception to this are for the `users` table where we store a stacker's
|
|||||||
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).
|
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
|
### 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 (see [_phantom reads_](https://www.postgresql.org/docs/16/transaction-iso.html)).
|
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:
|
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
|
- independent statements
|
||||||
- `WITH` queries (CTEs) in the same statement
|
- `WITH` queries (CTEs) in the same statement
|
||||||
|
|||||||
@ -62,7 +62,7 @@ export async function onPaid ({ invoice, actId }, { tx }) {
|
|||||||
// denormalize downzaps
|
// denormalize downzaps
|
||||||
await tx.$executeRaw`
|
await tx.$executeRaw`
|
||||||
WITH territory AS (
|
WITH territory AS (
|
||||||
SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName"
|
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
|
||||||
FROM "Item" i
|
FROM "Item" i
|
||||||
LEFT JOIN "Item" r ON r.id = i."rootId"
|
LEFT JOIN "Item" r ON r.id = i."rootId"
|
||||||
WHERE i.id = ${itemAct.itemId}::INTEGER
|
WHERE i.id = ${itemAct.itemId}::INTEGER
|
||||||
|
|||||||
@ -227,7 +227,7 @@ async function performP2PAction (actionType, args, incomingContext) {
|
|||||||
await assertBelowMaxPendingInvoices(incomingContext)
|
await assertBelowMaxPendingInvoices(incomingContext)
|
||||||
|
|
||||||
const description = await paidActions[actionType].describe(args, incomingContext)
|
const description = await paidActions[actionType].describe(args, incomingContext)
|
||||||
const { invoice, wrappedInvoice, protocol, maxFee } = await createWrappedInvoice(userId, {
|
const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, {
|
||||||
msats: cost,
|
msats: cost,
|
||||||
feePercent: sybilFeePercent,
|
feePercent: sybilFeePercent,
|
||||||
description,
|
description,
|
||||||
@ -239,7 +239,7 @@ async function performP2PAction (actionType, args, incomingContext) {
|
|||||||
invoiceArgs: {
|
invoiceArgs: {
|
||||||
bolt11: invoice,
|
bolt11: invoice,
|
||||||
wrappedBolt11: wrappedInvoice,
|
wrappedBolt11: wrappedInvoice,
|
||||||
protocol,
|
wallet,
|
||||||
maxFee
|
maxFee
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -269,7 +269,7 @@ async function performDirectAction (actionType, args, incomingContext) {
|
|||||||
|
|
||||||
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
|
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
|
||||||
|
|
||||||
for await (const { invoice, logger, protocol } of createUserInvoice(userId, {
|
for await (const { invoice, logger, wallet } of createUserInvoice(userId, {
|
||||||
msats: cost,
|
msats: cost,
|
||||||
description,
|
description,
|
||||||
expiry: INVOICE_EXPIRE_SECS
|
expiry: INVOICE_EXPIRE_SECS
|
||||||
@ -293,7 +293,7 @@ async function performDirectAction (actionType, args, incomingContext) {
|
|||||||
bolt11: invoice,
|
bolt11: invoice,
|
||||||
msats: cost,
|
msats: cost,
|
||||||
hash,
|
hash,
|
||||||
protocolId: protocol.id,
|
walletId: wallet.id,
|
||||||
receiverId: userId
|
receiverId: userId
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@ -345,27 +345,23 @@ export async function retryPaidAction (actionType, args, incomingContext) {
|
|||||||
where: {
|
where: {
|
||||||
invoiceId: failedInvoice.id
|
invoiceId: failedInvoice.id
|
||||||
},
|
},
|
||||||
include: {
|
|
||||||
protocol: {
|
|
||||||
include: {
|
include: {
|
||||||
wallet: true
|
wallet: true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (invoiceForward) {
|
if (invoiceForward) {
|
||||||
// this is a wrapped invoice, we need to retry it with receiver fallbacks
|
// this is a wrapped invoice, we need to retry it with receiver fallbacks
|
||||||
try {
|
try {
|
||||||
const { userId } = invoiceForward.protocol.wallet
|
const { userId } = invoiceForward.wallet
|
||||||
// this will return an invoice from the first receiver wallet that didn't fail yet and throw if none is available
|
// this will return an invoice from the first receiver wallet that didn't fail yet and throw if none is available
|
||||||
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, protocol, maxFee } = await createWrappedInvoice(userId, {
|
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, {
|
||||||
msats: failedInvoice.msatsRequested,
|
msats: failedInvoice.msatsRequested,
|
||||||
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
|
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
|
||||||
description: await action.describe?.(actionArgs, retryContext),
|
description: await action.describe?.(actionArgs, retryContext),
|
||||||
expiry: INVOICE_EXPIRE_SECS
|
expiry: INVOICE_EXPIRE_SECS
|
||||||
}, retryContext)
|
}, retryContext)
|
||||||
invoiceArgs = { bolt11, wrappedBolt11, protocol, maxFee }
|
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('failed to retry wrapped invoice, falling back to SN:', err)
|
console.log('failed to retry wrapped invoice, falling back to SN:', err)
|
||||||
}
|
}
|
||||||
@ -433,7 +429,7 @@ async function createSNInvoice (actionType, args, context) {
|
|||||||
|
|
||||||
async function createDbInvoice (actionType, args, context) {
|
async function createDbInvoice (actionType, args, context) {
|
||||||
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context
|
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context
|
||||||
const { bolt11, wrappedBolt11, preimage, protocol, maxFee } = invoiceArgs
|
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs
|
||||||
|
|
||||||
const db = tx ?? models
|
const db = tx ?? models
|
||||||
|
|
||||||
@ -472,9 +468,9 @@ async function createDbInvoice (actionType, args, context) {
|
|||||||
invoice: {
|
invoice: {
|
||||||
create: invoiceData
|
create: invoiceData
|
||||||
},
|
},
|
||||||
protocol: {
|
wallet: {
|
||||||
connect: {
|
connect: {
|
||||||
id: protocol.id
|
id: wallet.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { ANON_ITEM_SPAM_INTERVAL, ANON_FEE_MULTIPLIER, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||||
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers, notifyThreadSubscribers } from '@/lib/webPush'
|
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers, notifyThreadSubscribers } from '@/lib/webPush'
|
||||||
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
||||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||||
import { GqlInputError } from '@/lib/error'
|
import { GqlInputError } from '@/lib/error'
|
||||||
import { throwOnExpiredUploads } from '@/api/resolvers/upload'
|
|
||||||
|
|
||||||
export const anonable = true
|
export const anonable = true
|
||||||
|
|
||||||
@ -39,12 +38,12 @@ export async function getBaseCost ({ models, bio, parentId, subName }) {
|
|||||||
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
|
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
|
||||||
const baseCost = await getBaseCost({ models, bio, parentId, subName })
|
const baseCost = await getBaseCost({ models, bio, parentId, subName })
|
||||||
|
|
||||||
// cost = baseCost * 10^num_items_in_10m * 10 (ANON_FEE_MULTIPLIER constant) or 1 (user) + upload fees + boost
|
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + upload fees + boost
|
||||||
const [{ cost }] = await models.$queryRaw`
|
const [{ cost }] = await models.$queryRaw`
|
||||||
SELECT ${baseCost}::INTEGER
|
SELECT ${baseCost}::INTEGER
|
||||||
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? USER_ID.anon}::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?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
|
||||||
* ${me ? 1 : ANON_FEE_MULTIPLIER}::INTEGER
|
* ${me ? 1 : 100}::INTEGER
|
||||||
+ (SELECT "nUnpaid" * "uploadFeesMsats"
|
+ (SELECT "nUnpaid" * "uploadFeesMsats"
|
||||||
FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
|
FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
|
||||||
+ ${satsToMsats(boost)}::INTEGER as cost`
|
+ ${satsToMsats(boost)}::INTEGER as cost`
|
||||||
@ -62,7 +61,15 @@ export async function perform (args, context) {
|
|||||||
const { tx, me, cost } = context
|
const { tx, me, cost } = context
|
||||||
const boostMsats = satsToMsats(boost)
|
const boostMsats = satsToMsats(boost)
|
||||||
|
|
||||||
await throwOnExpiredUploads(uploadIds, { tx })
|
const deletedUploads = []
|
||||||
|
for (const uploadId of uploadIds) {
|
||||||
|
if (!await tx.upload.findUnique({ where: { id: uploadId } })) {
|
||||||
|
deletedUploads.push(uploadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deletedUploads.length > 0) {
|
||||||
|
throw new Error(`upload(s) ${deletedUploads.join(', ')} are expired, consider reuploading.`)
|
||||||
|
}
|
||||||
|
|
||||||
let invoiceData = {}
|
let invoiceData = {}
|
||||||
if (invoiceId) {
|
if (invoiceId) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||||
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
|
import { uploadFees } from '../resolvers/upload'
|
||||||
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
||||||
import { notifyItemMention, notifyMention } from '@/lib/webPush'
|
import { notifyItemMention, notifyMention } from '@/lib/webPush'
|
||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
@ -17,7 +17,7 @@ export async function getCost ({ id, boost = 0, uploadIds, bio }, { me, models }
|
|||||||
// or more boost
|
// or more boost
|
||||||
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
|
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
|
||||||
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
|
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
|
||||||
const cost = totalFeesMsats + satsToMsats(boost - old.boost)
|
const cost = BigInt(totalFeesMsats) + satsToMsats(boost - old.boost)
|
||||||
|
|
||||||
if (cost > 0 && old.invoiceActionState && old.invoiceActionState !== 'PAID') {
|
if (cost > 0 && old.invoiceActionState && old.invoiceActionState !== 'PAID') {
|
||||||
throw new Error('creation invoice not paid')
|
throw new Error('creation invoice not paid')
|
||||||
@ -60,7 +60,6 @@ export async function perform (args, context) {
|
|||||||
const itemMentions = await getItemMentions(args, context)
|
const itemMentions = await getItemMentions(args, context)
|
||||||
const itemUploads = uploadIds.map(id => ({ uploadId: id }))
|
const itemUploads = uploadIds.map(id => ({ uploadId: id }))
|
||||||
|
|
||||||
await throwOnExpiredUploads(uploadIds, { tx })
|
|
||||||
await tx.upload.updateMany({
|
await tx.upload.updateMany({
|
||||||
where: { id: { in: uploadIds } },
|
where: { id: { in: uploadIds } },
|
||||||
data: { paid: true }
|
data: { paid: true }
|
||||||
@ -164,8 +163,7 @@ export async function nonCriticalSideEffects ({ invoice, id }, { models }) {
|
|||||||
where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) },
|
where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) },
|
||||||
include: {
|
include: {
|
||||||
mentions: true,
|
mentions: true,
|
||||||
itemReferrers: { include: { refereeItem: true } },
|
itemReferrers: { include: { refereeItem: true } }
|
||||||
user: true
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// compare timestamps to only notify if mention or item referral was just created to avoid duplicates on edits
|
// compare timestamps to only notify if mention or item referral was just created to avoid duplicates on edits
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { PAID_ACTION_PAYMENT_METHODS, PROXY_RECEIVE_FEE_PERCENT } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
||||||
import { toPositiveBigInt, numWithUnits, msatsToSats } from '@/lib/format'
|
import { toPositiveBigInt, numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
|
||||||
import { notifyDeposit } from '@/lib/webPush'
|
import { notifyDeposit } from '@/lib/webPush'
|
||||||
import { getInvoiceableWallets } from '@/wallets/server'
|
import { getInvoiceableWallets } from '@/wallets/server'
|
||||||
|
|
||||||
@ -16,20 +16,22 @@ export async function getCost ({ msats }) {
|
|||||||
|
|
||||||
export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) {
|
export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) {
|
||||||
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null
|
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null
|
||||||
|
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && !me?.directReceive) return null
|
||||||
// don't fallback to direct if proxy is enabled to always hide stacker's node pubkey
|
|
||||||
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && me?.proxyReceive) return null
|
|
||||||
|
|
||||||
const wallets = await getInvoiceableWallets(me.id, { models })
|
const wallets = await getInvoiceableWallets(me.id, { models })
|
||||||
if (wallets.length === 0) {
|
if (wallets.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cost < satsToMsats(me.receiveCreditsBelowSats)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return me.id
|
return me.id
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSybilFeePercent () {
|
export async function getSybilFeePercent () {
|
||||||
return PROXY_RECEIVE_FEE_PERCENT
|
return 10n
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function perform ({
|
export async function perform ({
|
||||||
@ -56,6 +58,24 @@ export async function describe ({ description }, { me, cost, paymentMethod, sybi
|
|||||||
return description ?? `SN: ${me?.name ?? ''} receives ${numWithUnits(msatsToSats(cost - fee))}`
|
return description ?? `SN: ${me?.name ?? ''} receives ${numWithUnits(msatsToSats(cost - fee))}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function onPaid ({ invoice }, { tx }) {
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error('invoice is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2P lnurlp does not need to update the user's balance
|
||||||
|
if (invoice?.invoiceForward) return
|
||||||
|
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: invoice.userId },
|
||||||
|
data: {
|
||||||
|
mcredits: {
|
||||||
|
increment: invoice.msatsReceived
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function nonCriticalSideEffects ({ invoice }, { models }) {
|
export async function nonCriticalSideEffects ({ invoice }, { models }) {
|
||||||
await notifyDeposit(invoice.userId, invoice)
|
await notifyDeposit(invoice.userId, invoice)
|
||||||
await models.$executeRaw`
|
await models.$executeRaw`
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/consta
|
|||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
import { nextBilling } from '@/lib/territory'
|
import { nextBilling } from '@/lib/territory'
|
||||||
import { initialTrust } from './lib/territory'
|
import { initialTrust } from './lib/territory'
|
||||||
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
|
|
||||||
|
|
||||||
export const anonable = false
|
export const anonable = false
|
||||||
|
|
||||||
@ -12,9 +11,8 @@ export const paymentMethods = [
|
|||||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function getCost ({ billingType, uploadIds }, { models, me }) {
|
export async function getCost ({ billingType }) {
|
||||||
const { totalFees } = await uploadFees(uploadIds, { models, me })
|
return satsToMsats(TERRITORY_PERIOD_COST(billingType))
|
||||||
return satsToMsats(BigInt(TERRITORY_PERIOD_COST(billingType)) + totalFees)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
|
export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
|
||||||
@ -23,19 +21,6 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
|
|||||||
const billedLastAt = new Date()
|
const billedLastAt = new Date()
|
||||||
const billPaidUntil = nextBilling(billedLastAt, billingType)
|
const billPaidUntil = nextBilling(billedLastAt, billingType)
|
||||||
|
|
||||||
await throwOnExpiredUploads(data.uploadIds, { tx })
|
|
||||||
if (data.uploadIds.length > 0) {
|
|
||||||
await tx.upload.updateMany({
|
|
||||||
where: {
|
|
||||||
id: { in: data.uploadIds }
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
paid: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
delete data.uploadIds
|
|
||||||
|
|
||||||
const sub = await tx.sub.create({
|
const sub = await tx.sub.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
|
|||||||
@ -36,15 +36,8 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
|
|||||||
data.userId = me.id
|
data.userId = me.id
|
||||||
|
|
||||||
if (sub.userId !== me.id) {
|
if (sub.userId !== me.id) {
|
||||||
try {
|
|
||||||
// XXX this will throw if this transfer has already happened
|
|
||||||
// TODO: upsert this
|
|
||||||
await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } })
|
await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } })
|
||||||
// this will throw if the prior user has already unsubscribed
|
|
||||||
await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } })
|
await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } })
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.subAct.create({
|
await tx.subAct.create({
|
||||||
@ -85,16 +78,9 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const trust = initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
|
await tx.userSubTrust.createMany({
|
||||||
for (const t of trust) {
|
data: initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
|
||||||
await tx.userSubTrust.upsert({
|
|
||||||
where: {
|
|
||||||
userId_subName: { userId: t.userId, subName: t.subName }
|
|
||||||
},
|
|
||||||
update: t,
|
|
||||||
create: t
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
return updatedSub
|
return updatedSub
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/consta
|
|||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
import { proratedBillingCost } from '@/lib/territory'
|
import { proratedBillingCost } from '@/lib/territory'
|
||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
|
|
||||||
|
|
||||||
export const anonable = false
|
export const anonable = false
|
||||||
|
|
||||||
@ -12,16 +11,18 @@ export const paymentMethods = [
|
|||||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function getCost ({ oldName, billingType, uploadIds }, { models, me }) {
|
export async function getCost ({ oldName, billingType }, { models }) {
|
||||||
const oldSub = await models.sub.findUnique({
|
const oldSub = await models.sub.findUnique({
|
||||||
where: {
|
where: {
|
||||||
name: oldName
|
name: oldName
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const { totalFees } = await uploadFees(uploadIds, { models, me })
|
const cost = proratedBillingCost(oldSub, billingType)
|
||||||
|
if (!cost) {
|
||||||
|
return 0n
|
||||||
|
}
|
||||||
|
|
||||||
const cost = BigInt(proratedBillingCost(oldSub, billingType)) + totalFees
|
|
||||||
return satsToMsats(cost)
|
return satsToMsats(cost)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,19 +63,6 @@ export async function perform ({ oldName, invoiceId, ...data }, { me, cost, tx }
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await throwOnExpiredUploads(data.uploadIds, { tx })
|
|
||||||
if (data.uploadIds.length > 0) {
|
|
||||||
await tx.upload.updateMany({
|
|
||||||
where: {
|
|
||||||
id: { in: data.uploadIds }
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
paid: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
delete data.uploadIds
|
|
||||||
|
|
||||||
return await tx.sub.update({
|
return await tx.sub.update({
|
||||||
data,
|
data,
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@ -39,11 +39,11 @@ export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models,
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocols = await getInvoiceableWallets(item.userId, { models })
|
const wallets = await getInvoiceableWallets(item.userId, { models })
|
||||||
|
|
||||||
// request peer invoice if they have an attached wallet and have not forwarded the item
|
// request peer invoice if they have an attached wallet and have not forwarded the item
|
||||||
// and the receiver doesn't want to receive credits
|
// and the receiver doesn't want to receive credits
|
||||||
if (protocols.length > 0 &&
|
if (wallets.length > 0 &&
|
||||||
item.itemForwards.length === 0 &&
|
item.itemForwards.length === 0 &&
|
||||||
sats >= item.user.receiveCreditsBelowSats) {
|
sats >= item.user.receiveCreditsBelowSats) {
|
||||||
return item.userId
|
return item.userId
|
||||||
@ -151,7 +151,7 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
|
|||||||
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
|
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
|
||||||
await tx.$queryRaw`
|
await tx.$queryRaw`
|
||||||
WITH territory AS (
|
WITH territory AS (
|
||||||
SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName"
|
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
|
||||||
FROM "Item" i
|
FROM "Item" i
|
||||||
LEFT JOIN "Item" r ON r.id = i."rootId"
|
LEFT JOIN "Item" r ON r.id = i."rootId"
|
||||||
WHERE i.id = ${itemAct.itemId}::INTEGER
|
WHERE i.id = ${itemAct.itemId}::INTEGER
|
||||||
|
|||||||
@ -6,9 +6,9 @@ import { parsePaymentRequest, payViaPaymentRequest } from 'ln-service'
|
|||||||
// paying actions are completely distinct from paid actions
|
// paying actions are completely distinct from paid actions
|
||||||
// and there's only one paying action: send
|
// and there's only one paying action: send
|
||||||
// ... still we want the api to at least be similar
|
// ... still we want the api to at least be similar
|
||||||
export default async function performPayingAction ({ bolt11, maxFee, protocolId }, { me, models, lnd }) {
|
export default async function performPayingAction ({ bolt11, maxFee, walletId }, { me, models, lnd }) {
|
||||||
try {
|
try {
|
||||||
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, protocolId)
|
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, walletId)
|
||||||
|
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new Error('You must be logged in to perform this action')
|
throw new Error('You must be logged in to perform this action')
|
||||||
@ -34,8 +34,8 @@ export default async function performPayingAction ({ bolt11, maxFee, protocolId
|
|||||||
msatsPaying: toPositiveBigInt(decoded.mtokens),
|
msatsPaying: toPositiveBigInt(decoded.mtokens),
|
||||||
msatsFeePaying: satsToMsats(maxFee),
|
msatsFeePaying: satsToMsats(maxFee),
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
protocolId,
|
walletId,
|
||||||
autoWithdraw: !!protocolId
|
autoWithdraw: !!walletId
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import user from './user'
|
import user from './user'
|
||||||
import message from './message'
|
import message from './message'
|
||||||
import item from './item'
|
import item from './item'
|
||||||
import walletV1 from './wallet'
|
import wallet from './wallet'
|
||||||
import walletV2 from '@/wallets/server/resolvers'
|
|
||||||
import lnurl from './lnurl'
|
import lnurl from './lnurl'
|
||||||
import notifications from './notifications'
|
import notifications from './notifications'
|
||||||
import invite from './invite'
|
import invite from './invite'
|
||||||
@ -20,6 +19,7 @@ import chainFee from './chainFee'
|
|||||||
import { GraphQLScalarType, Kind } from 'graphql'
|
import { GraphQLScalarType, Kind } from 'graphql'
|
||||||
import { createIntScalar } from 'graphql-scalar'
|
import { createIntScalar } from 'graphql-scalar'
|
||||||
import paidAction from './paidAction'
|
import paidAction from './paidAction'
|
||||||
|
import vault from './vault'
|
||||||
|
|
||||||
const date = new GraphQLScalarType({
|
const date = new GraphQLScalarType({
|
||||||
name: 'Date',
|
name: 'Date',
|
||||||
@ -54,6 +54,6 @@ const limit = createIntScalar({
|
|||||||
maximum: 1000
|
maximum: 1000
|
||||||
})
|
})
|
||||||
|
|
||||||
export default [user, item, message, walletV1, walletV2, lnurl, notifications, invite, sub,
|
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
||||||
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
|
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
|
||||||
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction]
|
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
FULL_COMMENTS_THRESHOLD
|
FULL_COMMENTS_THRESHOLD
|
||||||
} from '@/lib/constants'
|
} from '@/lib/constants'
|
||||||
import { msatsToSats } from '@/lib/format'
|
import { msatsToSats } from '@/lib/format'
|
||||||
|
import { parse } from 'tldts'
|
||||||
import uu from 'url-unshort'
|
import uu from 'url-unshort'
|
||||||
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, validateSchema } from '@/lib/validate'
|
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, validateSchema } from '@/lib/validate'
|
||||||
import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
|
import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
|
||||||
@ -25,15 +26,11 @@ import assertApiKeyNotPermitted from './apiKey'
|
|||||||
import performPaidAction from '../paidAction'
|
import performPaidAction from '../paidAction'
|
||||||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||||
import { verifyHmac } from './wallet'
|
import { verifyHmac } from './wallet'
|
||||||
import { parse } from 'tldts'
|
|
||||||
import { shuffleArray } from '@/lib/rand'
|
|
||||||
|
|
||||||
function commentsOrderByClause (me, models, sort) {
|
function commentsOrderByClause (me, models, sort) {
|
||||||
const sharedSortsArray = []
|
const sharedSortsArray = []
|
||||||
sharedSortsArray.push('("Item"."pinId" IS NOT NULL) DESC')
|
sharedSortsArray.push('("Item"."pinId" IS NOT NULL) DESC')
|
||||||
sharedSortsArray.push('("Item"."deletedAt" IS NULL) DESC')
|
sharedSortsArray.push('("Item"."deletedAt" IS NULL) DESC')
|
||||||
// outlawed items should be at the bottom
|
|
||||||
sharedSortsArray.push(`NOT ("Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD} OR "Item".outlawed) DESC`)
|
|
||||||
const sharedSorts = sharedSortsArray.join(', ')
|
const sharedSorts = sharedSortsArray.join(', ')
|
||||||
|
|
||||||
if (sort === 'recent') {
|
if (sort === 'recent') {
|
||||||
@ -512,7 +509,7 @@ export default {
|
|||||||
${whereClause(
|
${whereClause(
|
||||||
'"parentId" IS NULL',
|
'"parentId" IS NULL',
|
||||||
'"Item"."deletedAt" IS NULL',
|
'"Item"."deletedAt" IS NULL',
|
||||||
activeOrMine(me),
|
'"Item"."status" = \'ACTIVE\'',
|
||||||
'created_at <= $1',
|
'created_at <= $1',
|
||||||
'"pinId" IS NULL',
|
'"pinId" IS NULL',
|
||||||
subClause(sub, 4)
|
subClause(sub, 4)
|
||||||
@ -595,13 +592,7 @@ export default {
|
|||||||
const response = await fetch(ensureProtocol(url), { redirect: 'follow' })
|
const response = await fetch(ensureProtocol(url), { redirect: 'follow' })
|
||||||
const html = await response.text()
|
const html = await response.text()
|
||||||
const doc = domino.createWindow(html).document
|
const doc = domino.createWindow(html).document
|
||||||
const titleRuleSet = {
|
const metadata = getMetadata(doc, url, { title: metadataRuleSets.title, publicationDate: publicationDateRuleSet })
|
||||||
rules: [
|
|
||||||
['h1 > yt-formatted-string.ytd-watch-metadata', el => el.getAttribute('title')],
|
|
||||||
...metadataRuleSets.title.rules
|
|
||||||
]
|
|
||||||
}
|
|
||||||
const metadata = getMetadata(doc, url, { title: titleRuleSet, publicationDate: publicationDateRuleSet })
|
|
||||||
const dateHint = ` (${metadata.publicationDate?.getFullYear()})`
|
const dateHint = ` (${metadata.publicationDate?.getFullYear()})`
|
||||||
const moreThanOneYearAgo = metadata.publicationDate && metadata.publicationDate < datePivot(new Date(), { years: -1 })
|
const moreThanOneYearAgo = metadata.publicationDate && metadata.publicationDate < datePivot(new Date(), { years: -1 })
|
||||||
|
|
||||||
@ -622,6 +613,7 @@ export default {
|
|||||||
const urlObj = new URL(ensureProtocol(url))
|
const urlObj = new URL(ensureProtocol(url))
|
||||||
let { hostname, pathname } = urlObj
|
let { hostname, pathname } = urlObj
|
||||||
|
|
||||||
|
// remove subdomain from hostname
|
||||||
const parseResult = parse(urlObj.hostname)
|
const parseResult = parse(urlObj.hostname)
|
||||||
if (parseResult?.subdomain?.length > 0) {
|
if (parseResult?.subdomain?.length > 0) {
|
||||||
hostname = hostname.replace(`${parseResult.subdomain}.`, '')
|
hostname = hostname.replace(`${parseResult.subdomain}.`, '')
|
||||||
@ -647,9 +639,6 @@ export default {
|
|||||||
} else if (urlObj.hostname === 'yewtu.be') {
|
} else if (urlObj.hostname === 'yewtu.be') {
|
||||||
const matches = url.match(/(https?:\/\/)?yewtu\.be.*(v=|embed\/)(?<id>[_0-9a-z-]+)/i)
|
const matches = url.match(/(https?:\/\/)?yewtu\.be.*(v=|embed\/)(?<id>[_0-9a-z-]+)/i)
|
||||||
similar = `^(http(s)?:\\/\\/)?yewtu\\.be\\/(watch\\?v\\=|embed\\/)${matches?.groups?.id}&?`
|
similar = `^(http(s)?:\\/\\/)?yewtu\\.be\\/(watch\\?v\\=|embed\\/)${matches?.groups?.id}&?`
|
||||||
} else {
|
|
||||||
// only allow ending of mismatching search params
|
|
||||||
similar += '(?:\\?.*)?$'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await itemQueryWithMeta({
|
return await itemQueryWithMeta({
|
||||||
@ -658,7 +647,7 @@ export default {
|
|||||||
query: `
|
query: `
|
||||||
${SELECT}
|
${SELECT}
|
||||||
FROM "Item"
|
FROM "Item"
|
||||||
WHERE url ~* $1 AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID')
|
WHERE url ~* $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 3`
|
LIMIT 3`
|
||||||
}, similar)
|
}, similar)
|
||||||
@ -705,11 +694,7 @@ export default {
|
|||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
outlawed: false,
|
outlawed: false,
|
||||||
parentId: null,
|
parentId: null
|
||||||
OR: [
|
|
||||||
{ invoiceActionState: 'PAID' },
|
|
||||||
{ invoiceActionState: null }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
if (id) {
|
if (id) {
|
||||||
where.id = { not: Number(id) }
|
where.id = { not: Number(id) }
|
||||||
@ -739,24 +724,6 @@ export default {
|
|||||||
homeMaxBoost: homeAgg._max.boost || 0,
|
homeMaxBoost: homeAgg._max.boost || 0,
|
||||||
subMaxBoost: subAgg?._max.boost || 0
|
subMaxBoost: subAgg?._max.boost || 0
|
||||||
}
|
}
|
||||||
},
|
|
||||||
newComments: async (parent, { rootId, after }, { models, me }) => {
|
|
||||||
const comments = await itemQueryWithMeta({
|
|
||||||
me,
|
|
||||||
models,
|
|
||||||
query: `
|
|
||||||
${SELECT}
|
|
||||||
FROM "Item"
|
|
||||||
-- comments can be nested, so we need to get all comments that are descendants of the root
|
|
||||||
${whereClause(
|
|
||||||
'"Item".path <@ (SELECT path FROM "Item" WHERE id = $1 AND "Item"."lastCommentAt" > $2)',
|
|
||||||
activeOrMine(me),
|
|
||||||
'"Item"."created_at" > $2'
|
|
||||||
)}
|
|
||||||
ORDER BY "Item"."created_at" ASC`
|
|
||||||
}, Number(rootId), after)
|
|
||||||
|
|
||||||
return { comments }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -864,16 +831,8 @@ export default {
|
|||||||
const data = { itemId: Number(id), userId: me.id }
|
const data = { itemId: Number(id), userId: me.id }
|
||||||
const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } })
|
const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } })
|
||||||
if (old) {
|
if (old) {
|
||||||
await models.$executeRaw`
|
await models.threadSubscription.delete({ where: { userId_itemId: data } })
|
||||||
DELETE FROM "ThreadSubscription" ts
|
} else await models.threadSubscription.create({ data })
|
||||||
USING "Item" i
|
|
||||||
WHERE ts."userId" = ${me.id}
|
|
||||||
AND i.path <@ (SELECT path FROM "Item" WHERE id = ${Number(id)})
|
|
||||||
AND ts."itemId" = i.id
|
|
||||||
`
|
|
||||||
} else {
|
|
||||||
await models.threadSubscription.create({ data })
|
|
||||||
}
|
|
||||||
return { id }
|
return { id }
|
||||||
},
|
},
|
||||||
deleteItem: async (parent, { id }, { me, models }) => {
|
deleteItem: async (parent, { id }, { me, models }) => {
|
||||||
@ -1189,8 +1148,7 @@ export default {
|
|||||||
poll.meVoted = false
|
poll.meVoted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
poll.randPollOptions = item?.randPollOptions
|
poll.options = options
|
||||||
poll.options = poll.randPollOptions ? shuffleArray(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
|
||||||
@ -1491,7 +1449,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
|
|||||||
throw new GqlInputError('item can no longer be edited')
|
throw new GqlInputError('item can no longer be edited')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.url && !isJob({ subName, ...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)
|
||||||
}
|
}
|
||||||
@ -1506,7 +1464,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
|
|||||||
item = { subName, ...item }
|
item = { subName, ...item }
|
||||||
item.forwardUsers = await getForwardUsers(models, forward)
|
item.forwardUsers = await getForwardUsers(models, forward)
|
||||||
}
|
}
|
||||||
item.uploadIds = uploadIdsFromText(item.text)
|
item.uploadIds = uploadIdsFromText(item.text, { models })
|
||||||
|
|
||||||
// never change author of item
|
// never change author of item
|
||||||
item.userId = old.userId
|
item.userId = old.userId
|
||||||
@ -1525,7 +1483,7 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd
|
|||||||
item.userId = me ? Number(me.id) : USER_ID.anon
|
item.userId = me ? Number(me.id) : USER_ID.anon
|
||||||
|
|
||||||
item.forwardUsers = await getForwardUsers(models, forward)
|
item.forwardUsers = await getForwardUsers(models, forward)
|
||||||
item.uploadIds = uploadIdsFromText(item.text)
|
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)
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor'
|
|||||||
import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item'
|
import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item'
|
||||||
import { getInvoice, getWithdrawl } from './wallet'
|
import { getInvoice, getWithdrawl } from './wallet'
|
||||||
import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
|
import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
|
||||||
import { sendPushSubscriptionReply } from '@/lib/webPush'
|
import { replyToSubscription } from '@/lib/webPush'
|
||||||
import { getSub } from './sub'
|
import { getSub } from './sub'
|
||||||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||||
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
|
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
|
||||||
@ -316,36 +316,13 @@ export default {
|
|||||||
|
|
||||||
if (meFull.noteCowboyHat) {
|
if (meFull.noteCowboyHat) {
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'CowboyHat' AS type
|
`(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type
|
||||||
FROM "Streak"
|
FROM "Streak"
|
||||||
WHERE "userId" = $1
|
WHERE "userId" = $1
|
||||||
AND updated_at < $2
|
AND updated_at < $2
|
||||||
AND type = 'COWBOY_HAT'
|
|
||||||
ORDER BY "sortTime" DESC
|
ORDER BY "sortTime" DESC
|
||||||
LIMIT ${LIMIT})`
|
LIMIT ${LIMIT})`
|
||||||
)
|
)
|
||||||
for (const type of ['HORSE', 'GUN']) {
|
|
||||||
const gqlType = type.charAt(0) + type.slice(1).toLowerCase()
|
|
||||||
queries.push(
|
|
||||||
`(SELECT id::text, "startedAt" AS "sortTime", 0 as "earnedSats", 'New${gqlType}' AS type
|
|
||||||
FROM "Streak"
|
|
||||||
WHERE "userId" = $1
|
|
||||||
AND updated_at < $2
|
|
||||||
AND type = '${type}'::"StreakType"
|
|
||||||
ORDER BY "sortTime" DESC
|
|
||||||
LIMIT ${LIMIT})`
|
|
||||||
)
|
|
||||||
queries.push(
|
|
||||||
`(SELECT id::text AS id, "endedAt" AS "sortTime", 0 as "earnedSats", 'Lost${gqlType}' AS type
|
|
||||||
FROM "Streak"
|
|
||||||
WHERE "userId" = $1
|
|
||||||
AND updated_at < $2
|
|
||||||
AND "endedAt" IS NOT NULL
|
|
||||||
AND type = '${type}'::"StreakType"
|
|
||||||
ORDER BY "sortTime" DESC
|
|
||||||
LIMIT ${LIMIT})`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
queries.push(
|
queries.push(
|
||||||
@ -439,7 +416,7 @@ export default {
|
|||||||
console.log(`[webPush] created subscription for user ${me.id}: endpoint=${endpoint}`)
|
console.log(`[webPush] created subscription for user ${me.id}: endpoint=${endpoint}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendPushSubscriptionReply(dbPushSubscription)
|
await replyToSubscription(dbPushSubscription.id, { title: 'Stacker News notifications are now active' })
|
||||||
|
|
||||||
return dbPushSubscription
|
return dbPushSubscription
|
||||||
},
|
},
|
||||||
@ -484,13 +461,8 @@ export default {
|
|||||||
},
|
},
|
||||||
TerritoryTransfer: {
|
TerritoryTransfer: {
|
||||||
sub: async (n, args, { models, me }) => {
|
sub: async (n, args, { models, me }) => {
|
||||||
const [sub] = await models.$queryRaw`
|
const transfer = await models.territoryTransfer.findUnique({ where: { id: Number(n.id) }, include: { sub: true } })
|
||||||
SELECT "Sub".*
|
return transfer.sub
|
||||||
FROM "TerritoryTransfer"
|
|
||||||
JOIN "Sub" ON "Sub"."name" = "TerritoryTransfer"."subName"
|
|
||||||
WHERE "TerritoryTransfer"."id" = ${Number(n.id)}`
|
|
||||||
|
|
||||||
return sub
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
JobChanged: {
|
JobChanged: {
|
||||||
@ -528,14 +500,23 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
CowboyHat: {
|
Streak: {
|
||||||
days: async (n, args, { models }) => {
|
days: async (n, args, { models }) => {
|
||||||
const res = await models.$queryRaw`
|
const res = await models.$queryRaw`
|
||||||
SELECT "endedAt"::date - "startedAt"::date AS days
|
SELECT "endedAt" - "startedAt" AS days
|
||||||
FROM "Streak"
|
FROM "Streak"
|
||||||
WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL
|
WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
return res.length ? res[0].days : null
|
return res.length ? res[0].days : null
|
||||||
|
},
|
||||||
|
type: async (n, args, { models }) => {
|
||||||
|
const res = await models.$queryRaw`
|
||||||
|
SELECT "type"
|
||||||
|
FROM "Streak"
|
||||||
|
WHERE id = ${Number(n.id)}
|
||||||
|
`
|
||||||
|
return res.length ? res[0].type : null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Earn: {
|
Earn: {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { amountSchema, validateSchema } from '@/lib/validate'
|
import { amountSchema, validateSchema } from '@/lib/validate'
|
||||||
import { getAd, getItem } from './item'
|
import { getAd, getItem } from './item'
|
||||||
|
import { topUsers } from './user'
|
||||||
import performPaidAction from '../paidAction'
|
import performPaidAction from '../paidAction'
|
||||||
import { GqlInputError } from '@/lib/error'
|
import { GqlInputError } from '@/lib/error'
|
||||||
|
|
||||||
@ -151,6 +152,13 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Rewards: {
|
Rewards: {
|
||||||
|
leaderboard: async (parent, args, { models, ...context }) => {
|
||||||
|
// get to and from using postgres because it's easier to do there
|
||||||
|
const [{ to, from }] = await models.$queryRaw`
|
||||||
|
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`
|
||||||
|
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 500 }, { models, ...context })
|
||||||
|
},
|
||||||
total: async (parent, args, { models }) => {
|
total: async (parent, args, { models }) => {
|
||||||
if (!parent.total) {
|
if (!parent.total) {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
||||||
import { whenToFrom } from '@/lib/time'
|
import { whenToFrom } from '@/lib/time'
|
||||||
import { getItem, itemQueryWithMeta, SELECT } from './item'
|
import { getItem, itemQueryWithMeta, SELECT } from './item'
|
||||||
import { parse } from 'tldts'
|
|
||||||
|
|
||||||
function queryParts (q) {
|
function queryParts (q) {
|
||||||
const regex = /"([^"]*)"/gm
|
const regex = /"([^"]*)"/gm
|
||||||
@ -254,17 +253,24 @@ export default {
|
|||||||
|
|
||||||
// if search contains a url term, modify the query text
|
// if search contains a url term, modify the query text
|
||||||
if (url) {
|
if (url) {
|
||||||
let uri = url.slice(4)
|
const uri = url.slice(4)
|
||||||
termQueries.push({
|
let uriObj
|
||||||
match_bool_prefix: { url: { query: uri, operator: 'and', boost: 1000 } }
|
try {
|
||||||
})
|
uriObj = new URL(uri)
|
||||||
const parsed = parse(uri)
|
} catch {
|
||||||
if (parsed?.subdomain?.length > 0) {
|
try {
|
||||||
uri = uri.replace(`${parsed.subdomain}.`, '')
|
uriObj = new URL(`https://${uri}`)
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (uriObj) {
|
||||||
termQueries.push({
|
termQueries.push({
|
||||||
wildcard: { url: { value: `*${uri}*` } }
|
wildcard: { url: `*${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}*` }
|
||||||
})
|
})
|
||||||
|
termQueries.push({
|
||||||
|
match: { text: `${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}` }
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if nym, items must contain nym
|
// if nym, items must contain nym
|
||||||
@ -283,23 +289,25 @@ export default {
|
|||||||
|
|
||||||
// if quoted phrases, items must contain entire phrase
|
// if quoted phrases, items must contain entire phrase
|
||||||
for (const quote of quotes) {
|
for (const quote of quotes) {
|
||||||
filters.push({
|
|
||||||
multi_match: {
|
|
||||||
query: quote,
|
|
||||||
fields: ['title.exact', 'text.exact'],
|
|
||||||
type: 'phrase'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
termQueries.push({
|
termQueries.push({
|
||||||
multi_match: {
|
multi_match: {
|
||||||
query: quote,
|
query: quote,
|
||||||
fields: ['title.exact^10', 'text.exact'],
|
|
||||||
type: 'phrase',
|
type: 'phrase',
|
||||||
boost: 1000
|
fields: ['title', 'text']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// force the search to include the quoted phrase
|
||||||
|
filters.push({
|
||||||
|
multi_match: {
|
||||||
|
query: quote,
|
||||||
|
type: 'phrase',
|
||||||
|
fields: ['title', 'text']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// functions for boosting search rank by recency or popularity
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
case 'comments':
|
case 'comments':
|
||||||
functions.push({
|
functions.push({
|
||||||
@ -387,24 +395,6 @@ export default {
|
|||||||
fields: ['title^10', 'text'],
|
fields: ['title^10', 'text'],
|
||||||
boost: 1000
|
boost: 1000
|
||||||
}
|
}
|
||||||
},
|
|
||||||
// match on exact fields higher
|
|
||||||
{
|
|
||||||
multi_match: {
|
|
||||||
query,
|
|
||||||
type: 'best_fields',
|
|
||||||
fields: ['title.exact^10', 'text.exact'],
|
|
||||||
boost: 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// exact phrase matches higher
|
|
||||||
{
|
|
||||||
multi_match: {
|
|
||||||
query,
|
|
||||||
fields: ['title.exact^10', 'text.exact'],
|
|
||||||
type: 'phrase',
|
|
||||||
boost: 10000
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -416,7 +406,6 @@ export default {
|
|||||||
if (process.env.OPENSEARCH_MODEL_ID) {
|
if (process.env.OPENSEARCH_MODEL_ID) {
|
||||||
osQuery = {
|
osQuery = {
|
||||||
hybrid: {
|
hybrid: {
|
||||||
pagination_depth: 50,
|
|
||||||
queries: [
|
queries: [
|
||||||
{
|
{
|
||||||
bool: {
|
bool: {
|
||||||
@ -468,9 +457,7 @@ export default {
|
|||||||
highlight: {
|
highlight: {
|
||||||
fields: {
|
fields: {
|
||||||
title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
|
title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
|
||||||
'title.exact': { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
|
text: { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] }
|
||||||
text: { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] },
|
|
||||||
'text.exact': { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -505,14 +492,8 @@ export default {
|
|||||||
orderBy: 'ORDER BY rank ASC, msats DESC'
|
orderBy: 'ORDER BY rank ASC, msats DESC'
|
||||||
})).map((item, i) => {
|
})).map((item, i) => {
|
||||||
const e = sitems.body.hits.hits[i]
|
const e = sitems.body.hits.hits[i]
|
||||||
|
item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title
|
||||||
// prefer the fuzzier highlight for title
|
item.searchText = (e.highlight?.text && e.highlight.text.join(' ... ')) || undefined
|
||||||
item.searchTitle = e.highlight?.title?.[0] || e.highlight?.['title.exact']?.[0] || item.title
|
|
||||||
|
|
||||||
// prefer the exact highlight for text
|
|
||||||
const searchTextHighlight = e.highlight?.['text.exact'] || e.highlight?.text || []
|
|
||||||
item.searchText = searchTextHighlight?.join(' ... ')
|
|
||||||
|
|
||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { viewGroup } from './growth'
|
|||||||
import { notifyTerritoryTransfer } from '@/lib/webPush'
|
import { notifyTerritoryTransfer } from '@/lib/webPush'
|
||||||
import performPaidAction from '../paidAction'
|
import performPaidAction from '../paidAction'
|
||||||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||||
import { uploadIdsFromText } from './upload'
|
|
||||||
|
|
||||||
export async function getSub (parent, { name }, { models, me }) {
|
export async function getSub (parent, { name }, { models, me }) {
|
||||||
if (!name) return null
|
if (!name) return null
|
||||||
@ -36,27 +35,6 @@ export async function getSub (parent, { name }, { models, me }) {
|
|||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
sub: getSub,
|
sub: getSub,
|
||||||
subSuggestions: async (parent, { q, limit = 5 }, { models }) => {
|
|
||||||
let subs = []
|
|
||||||
if (q) {
|
|
||||||
subs = await models.$queryRaw`
|
|
||||||
SELECT name
|
|
||||||
FROM "Sub"
|
|
||||||
WHERE status = 'ACTIVE'
|
|
||||||
AND SIMILARITY(name, ${q}) > 0.1
|
|
||||||
ORDER BY SIMILARITY(name, ${q}) DESC
|
|
||||||
LIMIT ${limit}`
|
|
||||||
} else {
|
|
||||||
subs = await models.$queryRaw`
|
|
||||||
SELECT name
|
|
||||||
FROM "Sub"
|
|
||||||
WHERE status = 'ACTIVE'
|
|
||||||
ORDER BY name ASC
|
|
||||||
LIMIT ${limit}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return subs
|
|
||||||
},
|
|
||||||
subs: async (parent, args, { models, me }) => {
|
subs: async (parent, args, { models, me }) => {
|
||||||
if (me) {
|
if (me) {
|
||||||
const currentUser = await models.user.findUnique({ where: { id: me.id } })
|
const currentUser = await models.user.findUnique({ where: { id: me.id } })
|
||||||
@ -128,7 +106,7 @@ export default {
|
|||||||
subs
|
subs
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models, me }) => {
|
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models }) => {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw new GqlInputError('must supply user name')
|
throw new GqlInputError('must supply user name')
|
||||||
}
|
}
|
||||||
@ -157,50 +135,20 @@ export default {
|
|||||||
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
|
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
|
||||||
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
|
||||||
ss."userId" IS NOT NULL as "meSubscription",
|
|
||||||
ms."userId" IS NOT NULL as "meMuteSub"
|
|
||||||
FROM ${viewGroup(range, 'sub_stats')}
|
FROM ${viewGroup(range, 'sub_stats')}
|
||||||
JOIN "Sub" on "Sub".name = u.sub_name
|
JOIN "Sub" on "Sub".name = u.sub_name
|
||||||
LEFT JOIN "SubSubscription" ss ON ss."subName" = "Sub".name AND ss."userId" IS NOT DISTINCT FROM $4
|
|
||||||
LEFT JOIN "MuteSub" ms ON ms."subName" = "Sub".name AND ms."userId" IS NOT DISTINCT FROM $4
|
|
||||||
WHERE "Sub"."userId" = $3
|
WHERE "Sub"."userId" = $3
|
||||||
AND "Sub".status = 'ACTIVE'
|
AND "Sub".status = 'ACTIVE'
|
||||||
GROUP BY "Sub".name, ss."userId", ms."userId"
|
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 $5
|
OFFSET $4
|
||||||
LIMIT $6
|
LIMIT $5`, ...range, user.id, decodedCursor.offset, limit)
|
||||||
`, ...range, user.id, me?.id, decodedCursor.offset, limit)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
|
cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
|
||||||
subs
|
subs
|
||||||
}
|
}
|
||||||
},
|
|
||||||
mySubscribedSubs: async (parent, { cursor }, { models, me }) => {
|
|
||||||
if (!me) {
|
|
||||||
throw new GqlAuthenticationError()
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodedCursor = decodeCursor(cursor)
|
|
||||||
const subs = await models.$queryRaw`
|
|
||||||
SELECT "Sub".*,
|
|
||||||
"MuteSub"."userId" IS NOT NULL as "meMuteSub",
|
|
||||||
TRUE as "meSubscription"
|
|
||||||
FROM "SubSubscription"
|
|
||||||
JOIN "Sub" ON "SubSubscription"."subName" = "Sub".name
|
|
||||||
LEFT JOIN "MuteSub" ON "MuteSub"."subName" = "Sub".name AND "MuteSub"."userId" = ${me.id}
|
|
||||||
WHERE "SubSubscription"."userId" = ${me.id}
|
|
||||||
AND "Sub".status <> 'STOPPED'
|
|
||||||
ORDER BY "Sub".name ASC
|
|
||||||
OFFSET ${decodedCursor.offset}
|
|
||||||
LIMIT ${LIMIT}
|
|
||||||
`
|
|
||||||
|
|
||||||
return {
|
|
||||||
cursor: subs.length === LIMIT ? nextCursorEncoded(decodedCursor, LIMIT) : null,
|
|
||||||
subs
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
@ -211,8 +159,6 @@ export default {
|
|||||||
|
|
||||||
await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } })
|
await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } })
|
||||||
|
|
||||||
data.uploadIds = uploadIdsFromText(data.desc)
|
|
||||||
|
|
||||||
if (data.oldName) {
|
if (data.oldName) {
|
||||||
return await updateSub(parent, data, { me, models, lnd })
|
return await updateSub(parent, data, { me, models, lnd })
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -6,17 +6,7 @@ import { msatsToSats } from '@/lib/format'
|
|||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
uploadFees: async (parent, { s3Keys }, { models, me }) => {
|
uploadFees: async (parent, { s3Keys }, { models, me }) => {
|
||||||
const fees = await uploadFees(s3Keys, { models, me })
|
return uploadFees(s3Keys, { models, me })
|
||||||
// GraphQL doesn't support bigint
|
|
||||||
return {
|
|
||||||
totalFees: Number(fees.totalFees),
|
|
||||||
totalFeesMsats: Number(fees.totalFeesMsats),
|
|
||||||
uploadFees: Number(fees.uploadFees),
|
|
||||||
uploadFeesMsats: Number(fees.uploadFeesMsats),
|
|
||||||
nUnpaid: Number(fees.nUnpaid),
|
|
||||||
bytesUnpaid: Number(fees.bytesUnpaid),
|
|
||||||
bytes24h: Number(fees.bytes24h)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
@ -64,36 +54,17 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadIdsFromText (text) {
|
export function uploadIdsFromText (text, { models }) {
|
||||||
if (!text) return []
|
if (!text) return []
|
||||||
return [...new Set([...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 uploadFees (s3Keys, { models, me }) {
|
export async function uploadFees (s3Keys, { models, me }) {
|
||||||
const [{
|
// returns info object in this format:
|
||||||
bytes24h,
|
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, uploadFeesMsats: BigInt }
|
||||||
bytesUnpaid,
|
const [info] = await models.$queryRawUnsafe('SELECT * FROM upload_fees($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
|
||||||
nUnpaid,
|
const uploadFees = msatsToSats(info.uploadFeesMsats)
|
||||||
uploadFeesMsats
|
const totalFeesMsats = info.nUnpaid * Number(info.uploadFeesMsats)
|
||||||
}] = await models.$queryRaw`SELECT * FROM upload_fees(${me?.id ?? USER_ID.anon}::INTEGER, ${s3Keys}::INTEGER[])`
|
const totalFees = msatsToSats(totalFeesMsats)
|
||||||
const uploadFees = BigInt(msatsToSats(uploadFeesMsats))
|
return { ...info, uploadFees, totalFees, totalFeesMsats }
|
||||||
const totalFeesMsats = BigInt(nUnpaid) * uploadFeesMsats
|
|
||||||
const totalFees = BigInt(msatsToSats(totalFeesMsats))
|
|
||||||
return { bytes24h, bytesUnpaid, nUnpaid, uploadFees, uploadFeesMsats, totalFees, totalFeesMsats }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function throwOnExpiredUploads (uploadIds, { tx }) {
|
|
||||||
if (uploadIds.length === 0) return
|
|
||||||
|
|
||||||
const existingUploads = await tx.upload.findMany({
|
|
||||||
where: { id: { in: uploadIds } },
|
|
||||||
select: { id: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
const existingIds = new Set(existingUploads.map(upload => upload.id))
|
|
||||||
const deletedIds = uploadIds.filter(id => !existingIds.has(id))
|
|
||||||
|
|
||||||
if (deletedIds.length > 0) {
|
|
||||||
throw new Error(`upload(s) ${deletedIds.join(', ')} are expired, consider reuploading.`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import assertApiKeyNotPermitted from './apiKey'
|
|||||||
import { hashEmail } from '@/lib/crypto'
|
import { hashEmail } from '@/lib/crypto'
|
||||||
import { isMuted } from '@/lib/user'
|
import { isMuted } from '@/lib/user'
|
||||||
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
|
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
|
||||||
import { processCrop } from '@/worker/imgproxy'
|
|
||||||
|
|
||||||
const contributors = new Set()
|
const contributors = new Set()
|
||||||
|
|
||||||
@ -728,18 +727,6 @@ export default {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
cropPhoto: async (parent, { photoId, cropData }, { me, models }) => {
|
|
||||||
if (!me) {
|
|
||||||
throw new GqlAuthenticationError()
|
|
||||||
}
|
|
||||||
|
|
||||||
const croppedUrl = await processCrop({ photoId: Number(photoId), cropData })
|
|
||||||
if (!croppedUrl) {
|
|
||||||
throw new GqlInputError('can\'t crop photo')
|
|
||||||
}
|
|
||||||
|
|
||||||
return croppedUrl
|
|
||||||
},
|
|
||||||
setPhoto: async (parent, { photoId }, { me, models }) => {
|
setPhoto: async (parent, { photoId }, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GqlAuthenticationError()
|
throw new GqlAuthenticationError()
|
||||||
@ -911,22 +898,6 @@ export default {
|
|||||||
|
|
||||||
await models.user.update({ where: { id: me.id }, data: { hideWelcomeBanner: true } })
|
await models.user.update({ where: { id: me.id }, data: { hideWelcomeBanner: true } })
|
||||||
return true
|
return true
|
||||||
},
|
|
||||||
hideWalletRecvPrompt: async (parent, data, { me, models }) => {
|
|
||||||
if (!me) {
|
|
||||||
throw new GqlAuthenticationError()
|
|
||||||
}
|
|
||||||
|
|
||||||
await models.user.update({ where: { id: me.id }, data: { hideWalletRecvPrompt: true } })
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
setDiagnostics: async (parent, { diagnostics }, { me, models }) => {
|
|
||||||
if (!me) {
|
|
||||||
throw new GqlAuthenticationError()
|
|
||||||
}
|
|
||||||
|
|
||||||
await models.user.update({ where: { id: me.id }, data: { diagnostics } })
|
|
||||||
return diagnostics
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1100,9 +1071,6 @@ export default {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return !!user.tipRandomMin && !!user.tipRandomMax
|
return !!user.tipRandomMin && !!user.tipRandomMax
|
||||||
},
|
|
||||||
hideWalletRecvPrompt: async (user, args, { models }) => {
|
|
||||||
return user.hideWalletRecvPrompt || user.hasRecvWallet
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1114,17 +1082,19 @@ export default {
|
|||||||
|
|
||||||
return user.streak
|
return user.streak
|
||||||
},
|
},
|
||||||
hasSendWallet: async (user, args, { models }) => {
|
gunStreak: async (user, args, { models }) => {
|
||||||
if (user.hideCowboyHat) {
|
if (user.hideCowboyHat) {
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
return user.hasSendWallet
|
|
||||||
|
return user.gunStreak
|
||||||
},
|
},
|
||||||
hasRecvWallet: async (user, args, { models }) => {
|
horseStreak: async (user, args, { models }) => {
|
||||||
if (user.hideCowboyHat) {
|
if (user.hideCowboyHat) {
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
return user.hasRecvWallet
|
|
||||||
|
return user.horseStreak
|
||||||
},
|
},
|
||||||
maxStreak: async (user, args, { models }) => {
|
maxStreak: async (user, args, { models }) => {
|
||||||
if (user.hideCowboyHat) {
|
if (user.hideCowboyHat) {
|
||||||
@ -1132,9 +1102,8 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [{ max }] = await models.$queryRaw`
|
const [{ max }] = await models.$queryRaw`
|
||||||
SELECT MAX(COALESCE("endedAt"::date, (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt"::date)
|
SELECT MAX(COALESCE("endedAt", (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt")
|
||||||
FROM "Streak" WHERE "userId" = ${user.id}
|
FROM "Streak" WHERE "userId" = ${user.id}`
|
||||||
AND type = 'COWBOY_HAT'`
|
|
||||||
return max
|
return max
|
||||||
},
|
},
|
||||||
isContributor: async (user, args, { me }) => {
|
isContributor: async (user, args, { me }) => {
|
||||||
|
|||||||
75
api/resolvers/vault.js
Normal file
75
api/resolvers/vault.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { E_VAULT_KEY_EXISTS, GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
Query: {
|
||||||
|
getVaultEntry: async (parent, { key }, { me, models }, info) => {
|
||||||
|
if (!me) throw new GqlAuthenticationError()
|
||||||
|
if (!key) throw new GqlInputError('must have key')
|
||||||
|
|
||||||
|
const k = await models.vault.findUnique({
|
||||||
|
where: {
|
||||||
|
key,
|
||||||
|
userId: me.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return k
|
||||||
|
},
|
||||||
|
getVaultEntries: async (parent, { keysFilter }, { me, models }, info) => {
|
||||||
|
if (!me) throw new GqlAuthenticationError()
|
||||||
|
|
||||||
|
const entries = await models.vaultEntry.findMany({
|
||||||
|
where: {
|
||||||
|
userId: me.id,
|
||||||
|
key: keysFilter?.length
|
||||||
|
? {
|
||||||
|
in: keysFilter
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Mutation: {
|
||||||
|
// atomic vault migration
|
||||||
|
updateVaultKey: async (parent, { entries, hash }, { me, models }) => {
|
||||||
|
if (!me) throw new GqlAuthenticationError()
|
||||||
|
if (!hash) throw new GqlInputError('hash required')
|
||||||
|
const txs = []
|
||||||
|
|
||||||
|
const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } })
|
||||||
|
if (oldKeyHash) {
|
||||||
|
if (oldKeyHash !== hash) {
|
||||||
|
throw new GqlInputError('vault key already set', E_VAULT_KEY_EXISTS)
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
txs.push(models.user.update({
|
||||||
|
where: { id: me.id },
|
||||||
|
data: { vaultKeyHash: hash }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
txs.push(models.vaultEntry.update({
|
||||||
|
where: { userId_key: { userId: me.id, key: entry.key } },
|
||||||
|
data: { value: entry.value, iv: entry.iv }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
await models.$transaction(txs)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
clearVault: async (parent, args, { me, models }) => {
|
||||||
|
if (!me) throw new GqlAuthenticationError()
|
||||||
|
const txs = []
|
||||||
|
txs.push(models.user.update({
|
||||||
|
where: { id: me.id },
|
||||||
|
data: { vaultKeyHash: '' }
|
||||||
|
}))
|
||||||
|
txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } }))
|
||||||
|
await models.$transaction(txs)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,23 +5,90 @@ import {
|
|||||||
import crypto, { timingSafeEqual } from 'crypto'
|
import crypto, { timingSafeEqual } from 'crypto'
|
||||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
||||||
import { SELECT, itemQueryWithMeta } from './item'
|
import { SELECT, itemQueryWithMeta } from './item'
|
||||||
import { msatsToSats, msatsToSatsDecimal } from '@/lib/format'
|
import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
|
||||||
import {
|
import {
|
||||||
USER_ID, INVOICE_RETENTION_DAYS,
|
USER_ID, INVOICE_RETENTION_DAYS,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS,
|
||||||
|
WALLET_CREATE_INVOICE_TIMEOUT_MS,
|
||||||
WALLET_RETRY_AFTER_MS,
|
WALLET_RETRY_AFTER_MS,
|
||||||
WALLET_RETRY_BEFORE_MS,
|
WALLET_RETRY_BEFORE_MS,
|
||||||
WALLET_MAX_RETRIES
|
WALLET_MAX_RETRIES
|
||||||
} from '@/lib/constants'
|
} from '@/lib/constants'
|
||||||
import { validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
|
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
|
||||||
import assertGofacYourself from './ofac'
|
import assertGofacYourself from './ofac'
|
||||||
import assertApiKeyNotPermitted from './apiKey'
|
import assertApiKeyNotPermitted from './apiKey'
|
||||||
import { bolt11Tags } from '@/lib/bolt11'
|
import { bolt11Tags } from '@/lib/bolt11'
|
||||||
import { finalizeHodlInvoice } from '@/worker/wallet'
|
import { finalizeHodlInvoice } from '@/worker/wallet'
|
||||||
|
import walletDefs from '@/wallets/server'
|
||||||
|
import { generateResolverName, generateTypeDefName } from '@/wallets/graphql'
|
||||||
import { lnAddrOptions } from '@/lib/lnurl'
|
import { lnAddrOptions } from '@/lib/lnurl'
|
||||||
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
|
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
|
||||||
import { getNodeSockets } from '../lnd'
|
import { getNodeSockets, getOurPubkey } from '../lnd'
|
||||||
|
import validateWallet from '@/wallets/validate'
|
||||||
|
import { canReceive, getWalletByType } from '@/wallets/common'
|
||||||
import performPaidAction from '../paidAction'
|
import performPaidAction from '../paidAction'
|
||||||
import performPayingAction from '../payingAction'
|
import performPayingAction from '../payingAction'
|
||||||
|
import { timeoutSignal, withTimeout } from '@/lib/time'
|
||||||
|
|
||||||
|
function injectResolvers (resolvers) {
|
||||||
|
console.group('injected GraphQL resolvers:')
|
||||||
|
for (const walletDef of walletDefs) {
|
||||||
|
const resolverName = generateResolverName(walletDef.walletField)
|
||||||
|
console.log(resolverName)
|
||||||
|
resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
|
||||||
|
console.log('resolving', resolverName, { settings, validateLightning, vaultEntries, ...data })
|
||||||
|
|
||||||
|
let existingVaultEntries
|
||||||
|
if (typeof vaultEntries === 'undefined' && data.id) {
|
||||||
|
// this mutation was sent from an unsynced client
|
||||||
|
// to pass validation, we need to add the existing vault entries for validation
|
||||||
|
// in case the client is removing the receiving config
|
||||||
|
existingVaultEntries = await models.vaultEntry.findMany({
|
||||||
|
where: {
|
||||||
|
walletId: Number(data.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const validData = await validateWallet(walletDef,
|
||||||
|
{ ...data, ...settings, vaultEntries: vaultEntries ?? existingVaultEntries },
|
||||||
|
{ serverSide: true })
|
||||||
|
if (validData) {
|
||||||
|
data && Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
|
||||||
|
settings && Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// wallet in shape of db row
|
||||||
|
const wallet = {
|
||||||
|
field: walletDef.walletField,
|
||||||
|
type: walletDef.walletType,
|
||||||
|
userId: me?.id
|
||||||
|
}
|
||||||
|
const logger = walletLogger({ wallet, models })
|
||||||
|
|
||||||
|
return await upsertWallet({
|
||||||
|
wallet,
|
||||||
|
walletDef,
|
||||||
|
testCreateInvoice:
|
||||||
|
walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data })
|
||||||
|
? (data) => withTimeout(
|
||||||
|
walletDef.testCreateInvoice(data, {
|
||||||
|
logger,
|
||||||
|
signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS)
|
||||||
|
}),
|
||||||
|
WALLET_CREATE_INVOICE_TIMEOUT_MS)
|
||||||
|
: null
|
||||||
|
}, {
|
||||||
|
settings,
|
||||||
|
data,
|
||||||
|
vaultEntries
|
||||||
|
}, { logger, 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({
|
||||||
@ -87,6 +154,54 @@ export function verifyHmac (hash, hmac) {
|
|||||||
const resolvers = {
|
const resolvers = {
|
||||||
Query: {
|
Query: {
|
||||||
invoice: getInvoice,
|
invoice: getInvoice,
|
||||||
|
wallet: async (parent, { id }, { me, models }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new GqlAuthenticationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return await models.wallet.findUnique({
|
||||||
|
where: {
|
||||||
|
userId: me.id,
|
||||||
|
id: Number(id)
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
vaultEntries: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
walletByType: async (parent, { type }, { me, models }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new GqlAuthenticationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallet = await models.wallet.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: me.id,
|
||||||
|
type
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
vaultEntries: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return wallet
|
||||||
|
},
|
||||||
|
wallets: async (parent, args, { me, models }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new GqlAuthenticationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return await models.wallet.findMany({
|
||||||
|
include: {
|
||||||
|
vaultEntries: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
userId: me.id
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
priority: 'asc'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
withdrawl: getWithdrawl,
|
withdrawl: getWithdrawl,
|
||||||
direct: async (parent, { id }, { me, models }) => {
|
direct: async (parent, { id }, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
@ -292,6 +407,67 @@ const resolvers = {
|
|||||||
facts: history
|
facts: history
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
walletLogs: async (parent, { type, from, to, cursor }, { me, models }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new GqlAuthenticationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// we cursoring with the wallet logs on the client
|
||||||
|
// if we have from, don't use cursor
|
||||||
|
// regardless, store the state of the cursor for the next call
|
||||||
|
|
||||||
|
const decodedCursor = cursor ? decodeCursor(cursor) : { offset: 0, time: to ?? new Date() }
|
||||||
|
|
||||||
|
let logs = []
|
||||||
|
let nextCursor
|
||||||
|
if (from) {
|
||||||
|
logs = await models.walletLog.findMany({
|
||||||
|
where: {
|
||||||
|
userId: me.id,
|
||||||
|
wallet: type ?? undefined,
|
||||||
|
createdAt: {
|
||||||
|
gt: from ? new Date(Number(from)) : undefined,
|
||||||
|
lte: to ? new Date(Number(to)) : undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
invoice: true,
|
||||||
|
withdrawal: true
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ createdAt: 'desc' },
|
||||||
|
{ id: 'desc' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
nextCursor = nextCursorEncoded(decodedCursor, logs.length)
|
||||||
|
} else {
|
||||||
|
logs = await models.walletLog.findMany({
|
||||||
|
where: {
|
||||||
|
userId: me.id,
|
||||||
|
wallet: type ?? undefined,
|
||||||
|
createdAt: {
|
||||||
|
lte: decodedCursor.time
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
invoice: true,
|
||||||
|
withdrawal: true
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ createdAt: 'desc' },
|
||||||
|
{ id: 'desc' }
|
||||||
|
],
|
||||||
|
take: LIMIT,
|
||||||
|
skip: decodedCursor.offset
|
||||||
|
})
|
||||||
|
nextCursor = logs.length === LIMIT ? nextCursorEncoded(decodedCursor, logs.length) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cursor: nextCursor,
|
||||||
|
entries: logs
|
||||||
|
}
|
||||||
|
},
|
||||||
failedInvoices: async (parent, args, { me, models }) => {
|
failedInvoices: async (parent, args, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GqlAuthenticationError()
|
throw new GqlAuthenticationError()
|
||||||
@ -305,20 +481,38 @@ const resolvers = {
|
|||||||
AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval
|
AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval
|
||||||
AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval
|
AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval
|
||||||
AND "paymentAttempt" < ${WALLET_MAX_RETRIES}
|
AND "paymentAttempt" < ${WALLET_MAX_RETRIES}
|
||||||
AND (
|
|
||||||
"actionType" = 'ITEM_CREATE' OR
|
|
||||||
"actionType" = 'ZAP' OR
|
|
||||||
"actionType" = 'DOWN_ZAP' OR
|
|
||||||
"actionType" = 'POLL_VOTE' OR
|
|
||||||
"actionType" = 'BOOST'
|
|
||||||
)
|
|
||||||
ORDER BY id DESC`
|
ORDER BY id DESC`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Wallet: {
|
||||||
|
wallet: async (wallet) => {
|
||||||
|
return {
|
||||||
|
...wallet.wallet,
|
||||||
|
__resolveType: generateTypeDefName(wallet.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
WalletDetails: {
|
||||||
|
__resolveType: wallet => wallet.__resolveType
|
||||||
|
},
|
||||||
InvoiceOrDirect: {
|
InvoiceOrDirect: {
|
||||||
__resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType
|
__resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
|
createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => {
|
||||||
|
await validateSchema(amountSchema, { amount })
|
||||||
|
await assertGofacYourself({ models, headers })
|
||||||
|
|
||||||
|
const { invoice, paymentMethod } = await performPaidAction('RECEIVE', {
|
||||||
|
msats: satsToMsats(amount)
|
||||||
|
}, { models, lnd, me })
|
||||||
|
|
||||||
|
return {
|
||||||
|
...invoice,
|
||||||
|
__resolveType:
|
||||||
|
paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT ? 'Direct' : 'Invoice'
|
||||||
|
}
|
||||||
|
},
|
||||||
createWithdrawl: createWithdrawal,
|
createWithdrawl: createWithdrawal,
|
||||||
sendToLnAddr,
|
sendToLnAddr,
|
||||||
cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => {
|
cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => {
|
||||||
@ -379,6 +573,43 @@ const resolvers = {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
setWalletPriority: async (parent, { id, priority }, { me, models }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new GqlAuthenticationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
await models.wallet.update({ where: { userId: me.id, id: Number(id) }, data: { priority } })
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
removeWallet: async (parent, { id }, { me, models }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new GqlAuthenticationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallet = await models.wallet.findUnique({ where: { userId: me.id, id: Number(id) } })
|
||||||
|
if (!wallet) {
|
||||||
|
throw new GqlInputError('wallet not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = walletLogger({ wallet, models })
|
||||||
|
await models.wallet.delete({ where: { userId: me.id, id: Number(id) } })
|
||||||
|
|
||||||
|
if (canReceive({ def: getWalletByType(wallet.type), config: wallet.wallet })) {
|
||||||
|
logger.info('details for receiving deleted')
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
deleteWalletLogs: async (parent, { wallet }, { me, models }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new GqlAuthenticationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
await models.walletLog.deleteMany({ where: { userId: me.id, wallet } })
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
buyCredits: async (parent, { credits }, { me, models, lnd }) => {
|
buyCredits: async (parent, { credits }, { me, models, lnd }) => {
|
||||||
return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd })
|
return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd })
|
||||||
}
|
}
|
||||||
@ -522,12 +753,223 @@ const resolvers = {
|
|||||||
return item
|
return item
|
||||||
},
|
},
|
||||||
sats: fact => msatsToSatsDecimal(fact.msats)
|
sats: fact => msatsToSatsDecimal(fact.msats)
|
||||||
|
},
|
||||||
|
|
||||||
|
WalletLogEntry: {
|
||||||
|
context: async ({ level, context, invoice, withdrawal }, args, { models }) => {
|
||||||
|
const isError = ['error', 'warn'].includes(level.toLowerCase())
|
||||||
|
|
||||||
|
if (withdrawal) {
|
||||||
|
return {
|
||||||
|
...await logContextFromBolt11(withdrawal.bolt11),
|
||||||
|
...(withdrawal.preimage ? { preimage: withdrawal.preimage } : {}),
|
||||||
|
...(isError ? { max_fee: formatMsats(withdrawal.msatsFeePaying) } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// XXX never return invoice as context because it might leak sensitive sender details
|
||||||
|
// if (invoice) { ... }
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default resolvers
|
export default injectResolvers(resolvers)
|
||||||
|
|
||||||
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, protocol, logger }) {
|
const logContextFromBolt11 = async (bolt11) => {
|
||||||
|
const decoded = await parsePaymentRequest({ request: bolt11 })
|
||||||
|
return {
|
||||||
|
bolt11,
|
||||||
|
amount: formatMsats(decoded.mtokens),
|
||||||
|
payment_hash: decoded.id,
|
||||||
|
created_at: decoded.created_at,
|
||||||
|
expires_at: decoded.expires_at,
|
||||||
|
description: decoded.description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const walletLogger = ({ wallet, models }) => {
|
||||||
|
// no-op logger if wallet is not provided
|
||||||
|
if (!wallet) {
|
||||||
|
return {
|
||||||
|
ok: () => {},
|
||||||
|
info: () => {},
|
||||||
|
error: () => {},
|
||||||
|
warn: () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// server implementation of wallet logger interface on client
|
||||||
|
const log = (level) => async (message, ctx = {}) => {
|
||||||
|
try {
|
||||||
|
let { invoiceId, withdrawalId, ...context } = ctx
|
||||||
|
|
||||||
|
if (context.bolt11) {
|
||||||
|
// automatically populate context from bolt11 to avoid duplicating this code
|
||||||
|
context = {
|
||||||
|
...context,
|
||||||
|
...await logContextFromBolt11(context.bolt11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await models.walletLog.create({
|
||||||
|
data: {
|
||||||
|
userId: wallet.userId,
|
||||||
|
wallet: wallet.type,
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
context,
|
||||||
|
invoiceId,
|
||||||
|
withdrawalId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('error creating wallet log:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: (message, context) => log('SUCCESS')(message, context),
|
||||||
|
info: (message, context) => log('INFO')(message, context),
|
||||||
|
error: (message, context) => log('ERROR')(message, context),
|
||||||
|
warn: (message, context) => log('WARN')(message, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertWallet (
|
||||||
|
{ wallet, walletDef, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) {
|
||||||
|
if (!me) {
|
||||||
|
throw new GqlAuthenticationError()
|
||||||
|
}
|
||||||
|
assertApiKeyNotPermitted({ me })
|
||||||
|
|
||||||
|
if (testCreateInvoice) {
|
||||||
|
try {
|
||||||
|
await testCreateInvoice(data)
|
||||||
|
} catch (err) {
|
||||||
|
const message = 'failed to create test invoice: ' + (err.message || err.toString?.())
|
||||||
|
logger.error(message)
|
||||||
|
throw new GqlInputError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, enabled, priority, ...recvConfig } = data
|
||||||
|
|
||||||
|
const txs = []
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const oldVaultEntries = await models.vaultEntry.findMany({ where: { userId: me.id, walletId: Number(id) } })
|
||||||
|
|
||||||
|
// 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 = 'key') => a.filter(x => !b.find(y => y[key] === x[key]))
|
||||||
|
const intersectionMerge = (a = [], b = [], key = 'key') => a.filter(x => b.find(y => y[key] === x[key]))
|
||||||
|
.map(x => ({ [key]: x[key], ...b.find(y => y[key] === x[key]) }))
|
||||||
|
|
||||||
|
txs.push(
|
||||||
|
models.wallet.update({
|
||||||
|
where: { id: Number(id), userId: me.id },
|
||||||
|
data: {
|
||||||
|
enabled,
|
||||||
|
priority,
|
||||||
|
// client only wallets have no receive config and thus don't have their own table
|
||||||
|
...(Object.keys(recvConfig).length > 0
|
||||||
|
? {
|
||||||
|
[wallet.field]: {
|
||||||
|
upsert: {
|
||||||
|
create: recvConfig,
|
||||||
|
update: recvConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(vaultEntries
|
||||||
|
? {
|
||||||
|
vaultEntries: {
|
||||||
|
deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({
|
||||||
|
userId: me.id, key
|
||||||
|
})),
|
||||||
|
create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({
|
||||||
|
key, iv, value, userId: me.id
|
||||||
|
})),
|
||||||
|
update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({
|
||||||
|
where: { userId_key: { userId: me.id, key } },
|
||||||
|
data: { value, iv }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
vaultEntries: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
txs.push(
|
||||||
|
models.wallet.create({
|
||||||
|
include: {
|
||||||
|
vaultEntries: true
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
enabled,
|
||||||
|
priority,
|
||||||
|
userId: me.id,
|
||||||
|
type: wallet.type,
|
||||||
|
// client only wallets have no receive config and thus don't have their own table
|
||||||
|
...(Object.keys(recvConfig).length > 0 ? { [wallet.field]: { create: recvConfig } } : {}),
|
||||||
|
...(vaultEntries
|
||||||
|
? {
|
||||||
|
vaultEntries: {
|
||||||
|
createMany: {
|
||||||
|
data: vaultEntries?.map(({ key, iv, value }) => ({ key, iv, value, userId: me.id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings) {
|
||||||
|
txs.push(
|
||||||
|
models.user.update({
|
||||||
|
where: { id: me.id },
|
||||||
|
data: settings
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canReceive({ def: walletDef, config: recvConfig })) {
|
||||||
|
txs.push(
|
||||||
|
models.walletLog.createMany({
|
||||||
|
data: {
|
||||||
|
userId: me.id,
|
||||||
|
wallet: wallet.type,
|
||||||
|
level: 'SUCCESS',
|
||||||
|
message: id ? 'details for receiving updated' : 'details for receiving saved'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
models.walletLog.create({
|
||||||
|
data: {
|
||||||
|
userId: me.id,
|
||||||
|
wallet: wallet.type,
|
||||||
|
level: enabled ? 'SUCCESS' : 'INFO',
|
||||||
|
message: enabled ? 'receiving enabled' : 'receiving disabled'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [upsertedWallet] = await models.$transaction(txs)
|
||||||
|
return upsertedWallet
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, wallet, logger }) {
|
||||||
assertApiKeyNotPermitted({ me })
|
assertApiKeyNotPermitted({ me })
|
||||||
await validateSchema(withdrawlSchema, { invoice, maxFee })
|
await validateSchema(withdrawlSchema, { invoice, maxFee })
|
||||||
await assertGofacYourself({ models, headers })
|
await assertGofacYourself({ models, headers })
|
||||||
@ -577,10 +1019,10 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
|
|||||||
throw new GqlInputError('SN cannot pay an invoice that SN is proxying')
|
throw new GqlInputError('SN cannot pay an invoice that SN is proxying')
|
||||||
}
|
}
|
||||||
|
|
||||||
return await performPayingAction({ bolt11: invoice, maxFee, protocolId: protocol?.id }, { me, models, lnd })
|
return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
|
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
|
||||||
{ me, models, lnd, headers }) {
|
{ me, models, lnd, headers }) {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GqlAuthenticationError()
|
throw new GqlAuthenticationError()
|
||||||
@ -598,9 +1040,11 @@ async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer }
|
|||||||
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
|
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLnAddrInvoice (
|
export async function fetchLnAddrInvoice (
|
||||||
{ addr, amount, maxFee, comment, ...payer },
|
{ addr, amount, maxFee, comment, ...payer },
|
||||||
{ me, models, lnd }) {
|
{
|
||||||
|
me, models, lnd, autoWithdraw = false
|
||||||
|
}) {
|
||||||
const options = await lnAddrOptions(addr)
|
const options = await lnAddrOptions(addr)
|
||||||
await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
|
await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
|
||||||
|
|
||||||
@ -637,6 +1081,14 @@ async function fetchLnAddrInvoice (
|
|||||||
// decode invoice
|
// decode invoice
|
||||||
try {
|
try {
|
||||||
const decoded = await parsePaymentRequest({ request: res.pr })
|
const decoded = await parsePaymentRequest({ request: res.pr })
|
||||||
|
const ourPubkey = await getOurPubkey({ lnd })
|
||||||
|
if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
|
||||||
|
// unset lnaddr so we don't trigger another withdrawal with same destination
|
||||||
|
await models.wallet.deleteMany({
|
||||||
|
where: { userId: me.id, type: 'LIGHTNING_ADDRESS' }
|
||||||
|
})
|
||||||
|
throw new Error('automated withdrawals to other stackers are not allowed')
|
||||||
|
}
|
||||||
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
|
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
|
||||||
throw new Error('invoice has incorrect amount')
|
throw new Error('invoice has incorrect amount')
|
||||||
}
|
}
|
||||||
|
|||||||
17
api/slack/index.js
Normal file
17
api/slack/index.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { WebClient, LogLevel } from '@slack/web-api'
|
||||||
|
|
||||||
|
const slackClient = global.slackClient || (() => {
|
||||||
|
if (!process.env.SLACK_BOT_TOKEN && !process.env.SLACK_CHANNEL_ID) {
|
||||||
|
console.warn('SLACK_* env vars not set, skipping slack setup')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
console.log('initing slack client')
|
||||||
|
const client = new WebClient(process.env.SLACK_BOT_TOKEN, {
|
||||||
|
logLevel: LogLevel.INFO
|
||||||
|
})
|
||||||
|
return client
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') global.slackClient = slackClient
|
||||||
|
|
||||||
|
export default slackClient
|
||||||
@ -15,7 +15,7 @@ import { getServerSession } from 'next-auth/next'
|
|||||||
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
|
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
|
||||||
import { NOFOLLOW_LIMIT } from '@/lib/constants'
|
import { NOFOLLOW_LIMIT } from '@/lib/constants'
|
||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
import { MULTI_AUTH_ANON, MULTI_AUTH_POINTER } from '@/lib/auth'
|
import { MULTI_AUTH_ANON, MULTI_AUTH_LIST } from '@/lib/auth'
|
||||||
|
|
||||||
export default async function getSSRApolloClient ({ req, res, me = null }) {
|
export default async function getSSRApolloClient ({ req, res, me = null }) {
|
||||||
const session = req && await getServerSession(req, res, getAuthOptions(req))
|
const session = req && await getServerSession(req, res, getAuthOptions(req))
|
||||||
@ -156,7 +156,7 @@ export function getGetServerSideProps (
|
|||||||
|
|
||||||
// required to redirect to /signup on page reload
|
// required to redirect to /signup on page reload
|
||||||
// if we switched to anon and authentication is required
|
// if we switched to anon and authentication is required
|
||||||
if (req.cookies[MULTI_AUTH_POINTER] === MULTI_AUTH_ANON) {
|
if (req.cookies[MULTI_AUTH_LIST] === MULTI_AUTH_ANON) {
|
||||||
me = null
|
me = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 paidAction from './paidAction'
|
import paidAction from './paidAction'
|
||||||
|
import vault from './vault'
|
||||||
|
|
||||||
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, paidAction]
|
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault]
|
||||||
|
|||||||
@ -11,7 +11,6 @@ export default gql`
|
|||||||
auctionPosition(sub: String, id: ID, boost: Int): Int!
|
auctionPosition(sub: String, id: ID, boost: Int): Int!
|
||||||
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
|
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
|
||||||
itemRepetition(parentId: ID): Int!
|
itemRepetition(parentId: ID): Int!
|
||||||
newComments(rootId: ID, after: Date): Comments!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BoostPositions {
|
type BoostPositions {
|
||||||
@ -58,7 +57,7 @@ export default gql`
|
|||||||
text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction!
|
text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction!
|
||||||
upsertPoll(
|
upsertPoll(
|
||||||
id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date,
|
id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date,
|
||||||
randPollOptions: Boolean, hash: String, hmac: String): ItemPaidAction!
|
hash: String, hmac: String): ItemPaidAction!
|
||||||
updateNoteId(id: ID!, noteId: String!): Item!
|
updateNoteId(id: ID!, noteId: String!): Item!
|
||||||
upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction!
|
upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction!
|
||||||
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
|
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
|
||||||
@ -82,7 +81,6 @@ export default gql`
|
|||||||
meInvoiceActionState: InvoiceActionState
|
meInvoiceActionState: InvoiceActionState
|
||||||
count: Int!
|
count: Int!
|
||||||
options: [PollOption!]!
|
options: [PollOption!]!
|
||||||
randPollOptions: Boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Items {
|
type Items {
|
||||||
@ -149,7 +147,6 @@ export default gql`
|
|||||||
ncomments: Int!
|
ncomments: Int!
|
||||||
nDirectComments: Int!
|
nDirectComments: Int!
|
||||||
comments(sort: String, cursor: String): Comments!
|
comments(sort: String, cursor: String): Comments!
|
||||||
injected: Boolean!
|
|
||||||
path: String
|
path: String
|
||||||
position: Int
|
position: Int
|
||||||
prior: Int
|
prior: Int
|
||||||
|
|||||||
@ -75,6 +75,13 @@ export default gql`
|
|||||||
tipComments: Int!
|
tipComments: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Streak {
|
||||||
|
id: ID!
|
||||||
|
sortTime: Date!
|
||||||
|
days: Int
|
||||||
|
type: String!
|
||||||
|
}
|
||||||
|
|
||||||
type Earn {
|
type Earn {
|
||||||
id: ID!
|
id: ID!
|
||||||
earnedSats: Int!
|
earnedSats: Int!
|
||||||
@ -149,37 +156,11 @@ export default gql`
|
|||||||
sortTime: Date!
|
sortTime: Date!
|
||||||
}
|
}
|
||||||
|
|
||||||
type CowboyHat {
|
|
||||||
id: ID!
|
|
||||||
sortTime: Date!
|
|
||||||
days: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
type NewHorse {
|
|
||||||
id: ID!
|
|
||||||
sortTime: Date!
|
|
||||||
}
|
|
||||||
|
|
||||||
type LostHorse {
|
|
||||||
id: ID!
|
|
||||||
sortTime: Date!
|
|
||||||
}
|
|
||||||
|
|
||||||
type NewGun {
|
|
||||||
id: ID!
|
|
||||||
sortTime: Date!
|
|
||||||
}
|
|
||||||
|
|
||||||
type LostGun {
|
|
||||||
id: ID!
|
|
||||||
sortTime: Date!
|
|
||||||
}
|
|
||||||
|
|
||||||
union Notification = Reply | Votification | Mention
|
union Notification = Reply | Votification | Mention
|
||||||
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
|
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
|
||||||
| FollowActivity | ForwardedVotification | Revenue | SubStatus
|
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
|
||||||
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification
|
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification
|
||||||
| ReferralReward | CowboyHat | NewHorse | LostHorse | NewGun | LostGun
|
| ReferralReward
|
||||||
|
|
||||||
type Notifications {
|
type Notifications {
|
||||||
lastChecked: Date
|
lastChecked: Date
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export default gql`
|
|||||||
total: Int!
|
total: Int!
|
||||||
time: Date!
|
time: Date!
|
||||||
sources: [NameValue!]!
|
sources: [NameValue!]!
|
||||||
|
leaderboard: UsersNullable
|
||||||
ad: Item
|
ad: Item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,8 +7,6 @@ export default gql`
|
|||||||
subs: [Sub!]!
|
subs: [Sub!]!
|
||||||
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
|
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
|
||||||
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
|
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
|
||||||
mySubscribedSubs(cursor: String): Subs
|
|
||||||
subSuggestions(q: String!, limit: Limit): [Sub!]!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Subs {
|
type Subs {
|
||||||
|
|||||||
@ -29,34 +29,21 @@ export default gql`
|
|||||||
users: [User!]!
|
users: [User!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
input CropData {
|
|
||||||
x: Float!
|
|
||||||
y: Float!
|
|
||||||
width: Float!
|
|
||||||
height: Float!
|
|
||||||
originalWidth: Int!
|
|
||||||
originalHeight: Int!
|
|
||||||
scale: Float!
|
|
||||||
}
|
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
setName(name: String!): String
|
setName(name: String!): String
|
||||||
setSettings(settings: SettingsInput!): User
|
setSettings(settings: SettingsInput!): User
|
||||||
cropPhoto(photoId: ID!, cropData: CropData): String!
|
|
||||||
setPhoto(photoId: ID!): Int!
|
setPhoto(photoId: ID!): Int!
|
||||||
upsertBio(text: String!): ItemPaidAction!
|
upsertBio(text: String!): ItemPaidAction!
|
||||||
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
|
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
|
||||||
unlinkAuth(authType: String!): AuthMethods!
|
unlinkAuth(authType: String!): AuthMethods!
|
||||||
linkUnverifiedEmail(email: String!): Boolean
|
linkUnverifiedEmail(email: String!): Boolean
|
||||||
hideWelcomeBanner: Boolean
|
hideWelcomeBanner: Boolean
|
||||||
hideWalletRecvPrompt: Boolean
|
|
||||||
subscribeUserPosts(id: ID): User
|
subscribeUserPosts(id: ID): User
|
||||||
subscribeUserComments(id: ID): User
|
subscribeUserComments(id: ID): User
|
||||||
toggleMute(id: ID): User
|
toggleMute(id: ID): User
|
||||||
generateApiKey(id: ID!): String
|
generateApiKey(id: ID!): String
|
||||||
deleteApiKey(id: ID!): User
|
deleteApiKey(id: ID!): User
|
||||||
disableFreebies: Boolean
|
disableFreebies: Boolean
|
||||||
setDiagnostics(diagnostics: Boolean!): Boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type User {
|
type User {
|
||||||
@ -87,6 +74,7 @@ export default gql`
|
|||||||
|
|
||||||
input SettingsInput {
|
input SettingsInput {
|
||||||
autoDropBolt11s: Boolean!
|
autoDropBolt11s: Boolean!
|
||||||
|
diagnostics: Boolean!
|
||||||
noReferralLinks: Boolean!
|
noReferralLinks: Boolean!
|
||||||
fiatCurrency: String!
|
fiatCurrency: String!
|
||||||
satsFilter: Int!
|
satsFilter: Int!
|
||||||
@ -124,6 +112,10 @@ export default gql`
|
|||||||
zapUndos: Int
|
zapUndos: Int
|
||||||
wildWestMode: Boolean!
|
wildWestMode: Boolean!
|
||||||
withdrawMaxFeeDefault: Int!
|
withdrawMaxFeeDefault: Int!
|
||||||
|
proxyReceive: Boolean
|
||||||
|
directReceive: Boolean
|
||||||
|
receiveCreditsBelowSats: Int!
|
||||||
|
sendCreditsBelowSats: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthMethods {
|
type AuthMethods {
|
||||||
@ -149,18 +141,16 @@ export default gql`
|
|||||||
"""
|
"""
|
||||||
lastCheckedJobs: String
|
lastCheckedJobs: String
|
||||||
hideWelcomeBanner: Boolean!
|
hideWelcomeBanner: Boolean!
|
||||||
hideWalletRecvPrompt: Boolean!
|
|
||||||
tipPopover: Boolean!
|
tipPopover: Boolean!
|
||||||
upvotePopover: Boolean!
|
upvotePopover: Boolean!
|
||||||
hasInvites: Boolean!
|
hasInvites: Boolean!
|
||||||
apiKeyEnabled: Boolean!
|
apiKeyEnabled: Boolean!
|
||||||
showPassphrase: Boolean!
|
|
||||||
diagnostics: Boolean!
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
mirrors SettingsInput
|
mirrors SettingsInput
|
||||||
"""
|
"""
|
||||||
autoDropBolt11s: Boolean!
|
autoDropBolt11s: Boolean!
|
||||||
|
diagnostics: Boolean!
|
||||||
noReferralLinks: Boolean!
|
noReferralLinks: Boolean!
|
||||||
fiatCurrency: String!
|
fiatCurrency: String!
|
||||||
satsFilter: Int!
|
satsFilter: Int!
|
||||||
@ -201,9 +191,14 @@ export default gql`
|
|||||||
wildWestMode: Boolean!
|
wildWestMode: Boolean!
|
||||||
withdrawMaxFeeDefault: Int!
|
withdrawMaxFeeDefault: Int!
|
||||||
autoWithdrawThreshold: Int
|
autoWithdrawThreshold: Int
|
||||||
|
autoWithdrawMaxFeePercent: Float
|
||||||
|
autoWithdrawMaxFeeTotal: Int
|
||||||
vaultKeyHash: String
|
vaultKeyHash: String
|
||||||
vaultKeyHashUpdatedAt: Date
|
|
||||||
walletsUpdatedAt: Date
|
walletsUpdatedAt: Date
|
||||||
|
proxyReceive: Boolean
|
||||||
|
directReceive: Boolean
|
||||||
|
receiveCreditsBelowSats: Int!
|
||||||
|
sendCreditsBelowSats: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserOptional {
|
type UserOptional {
|
||||||
@ -216,9 +211,6 @@ export default gql`
|
|||||||
streak: Int
|
streak: Int
|
||||||
gunStreak: Int
|
gunStreak: Int
|
||||||
horseStreak: Int
|
horseStreak: Int
|
||||||
hasSendWallet: Boolean
|
|
||||||
hasRecvWallet: Boolean
|
|
||||||
hideWalletRecvPrompt: Boolean
|
|
||||||
maxStreak: Int
|
maxStreak: Int
|
||||||
isContributor: Boolean
|
isContributor: Boolean
|
||||||
githubId: String
|
githubId: String
|
||||||
|
|||||||
29
api/typeDefs/vault.js
Normal file
29
api/typeDefs/vault.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
|
export default gql`
|
||||||
|
type VaultEntry {
|
||||||
|
id: ID!
|
||||||
|
key: String!
|
||||||
|
iv: String!
|
||||||
|
value: String!
|
||||||
|
createdAt: Date!
|
||||||
|
updatedAt: Date!
|
||||||
|
}
|
||||||
|
|
||||||
|
input VaultEntryInput {
|
||||||
|
key: String!
|
||||||
|
iv: String!
|
||||||
|
value: String!
|
||||||
|
walletId: ID
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
getVaultEntry(key: String!): VaultEntry
|
||||||
|
getVaultEntries(keysFilter: [String!]): [VaultEntry!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
clearVault: Boolean
|
||||||
|
updateVaultKey(entries: [VaultEntryInput!]!, hash: String!): Boolean
|
||||||
|
}
|
||||||
|
`
|
||||||
@ -1,8 +1,66 @@
|
|||||||
import { gql } from 'graphql-tag'
|
import { gql } from 'graphql-tag'
|
||||||
|
import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName } from '@/wallets/graphql'
|
||||||
|
import { isServerField } from '@/wallets/common'
|
||||||
|
import walletDefs from '@/wallets/server'
|
||||||
|
|
||||||
const shared = 'walletId: ID, templateName: ID, enabled: Boolean!'
|
function injectTypeDefs (typeDefs) {
|
||||||
|
const injected = [rawTypeDefs(), mutationTypeDefs()]
|
||||||
|
return `${typeDefs}\n\n${injected.join('\n\n')}\n`
|
||||||
|
}
|
||||||
|
|
||||||
const typeDefs = gql`
|
function mutationTypeDefs () {
|
||||||
|
console.group('injected GraphQL mutations:')
|
||||||
|
|
||||||
|
const typeDefs = walletDefs.map((w) => {
|
||||||
|
let args = 'id: ID, '
|
||||||
|
const serverFields = w.fields
|
||||||
|
.filter(isServerField)
|
||||||
|
.map(fieldToGqlArgOptional)
|
||||||
|
if (serverFields.length > 0) args += serverFields.join(', ') + ','
|
||||||
|
args += 'enabled: Boolean, priority: Int, vaultEntries: [VaultEntryInput!], settings: AutowithdrawSettings, validateLightning: Boolean'
|
||||||
|
const resolverName = generateResolverName(w.walletField)
|
||||||
|
const typeDef = `${resolverName}(${args}): Wallet`
|
||||||
|
console.log(typeDef)
|
||||||
|
return typeDef
|
||||||
|
})
|
||||||
|
|
||||||
|
console.groupEnd()
|
||||||
|
|
||||||
|
return `extend type Mutation {\n${typeDefs.join('\n')}\n}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawTypeDefs () {
|
||||||
|
console.group('injected GraphQL type defs:')
|
||||||
|
|
||||||
|
const typeDefs = walletDefs.map((w) => {
|
||||||
|
let args = w.fields
|
||||||
|
.filter(isServerField)
|
||||||
|
.map(fieldToGqlArg)
|
||||||
|
.map(s => ' ' + s)
|
||||||
|
.join('\n')
|
||||||
|
if (!args) {
|
||||||
|
// add a placeholder arg so the type is not empty
|
||||||
|
args = ' _empty: Boolean'
|
||||||
|
}
|
||||||
|
const typeDefName = generateTypeDefName(w.walletType)
|
||||||
|
const typeDef = `type ${typeDefName} {\n${args}\n}`
|
||||||
|
console.log(typeDef)
|
||||||
|
return typeDef
|
||||||
|
})
|
||||||
|
|
||||||
|
let union = 'union WalletDetails = '
|
||||||
|
union += walletDefs.map((w) => {
|
||||||
|
const typeDefName = generateTypeDefName(w.walletType)
|
||||||
|
return typeDefName
|
||||||
|
}).join(' | ')
|
||||||
|
console.log(union)
|
||||||
|
|
||||||
|
console.groupEnd()
|
||||||
|
|
||||||
|
return typeDefs.join('\n\n') + union
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeDefs = `
|
||||||
extend type Query {
|
extend type Query {
|
||||||
invoice(id: ID!): Invoice!
|
invoice(id: ID!): Invoice!
|
||||||
withdrawl(id: ID!): Withdrawl!
|
withdrawl(id: ID!): Withdrawl!
|
||||||
@ -10,151 +68,23 @@ const typeDefs = gql`
|
|||||||
numBolt11s: Int!
|
numBolt11s: Int!
|
||||||
connectAddress: String!
|
connectAddress: String!
|
||||||
walletHistory(cursor: String, inc: String): History
|
walletHistory(cursor: String, inc: String): History
|
||||||
wallets: [WalletOrTemplate!]!
|
wallets(includeReceivers: Boolean, includeSenders: Boolean, onlyEnabled: Boolean, prioritySort: String): [Wallet!]!
|
||||||
wallet(id: ID, name: String): WalletOrTemplate
|
wallet(id: ID!): Wallet
|
||||||
walletSettings: WalletSettings!
|
walletByType(type: String!): Wallet
|
||||||
walletLogs(protocolId: Int, cursor: String, debug: Boolean): WalletLogs!
|
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
|
||||||
failedInvoices: [Invoice!]!
|
failedInvoices: [Invoice!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
|
createInvoice(amount: Int!): InvoiceOrDirect!
|
||||||
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
|
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
|
||||||
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, userCancel: Boolean): Invoice!
|
cancelInvoice(hash: String!, hmac: String, userCancel: Boolean): Invoice!
|
||||||
dropBolt11(hash: String!): Boolean
|
dropBolt11(hash: String!): Boolean
|
||||||
buyCredits(credits: Int!): BuyCreditsPaidAction!
|
|
||||||
|
|
||||||
# upserts
|
|
||||||
upsertWalletSendLNbits(
|
|
||||||
${shared},
|
|
||||||
url: String!,
|
|
||||||
apiKey: VaultEntryInput!
|
|
||||||
): WalletSendLNbits!
|
|
||||||
|
|
||||||
upsertWalletRecvLNbits(
|
|
||||||
${shared},
|
|
||||||
url: String!,
|
|
||||||
apiKey: String!
|
|
||||||
): WalletRecvLNbits!
|
|
||||||
|
|
||||||
upsertWalletSendPhoenixd(
|
|
||||||
${shared},
|
|
||||||
url: String!,
|
|
||||||
apiKey: VaultEntryInput!
|
|
||||||
): WalletSendPhoenixd!
|
|
||||||
|
|
||||||
upsertWalletRecvPhoenixd(
|
|
||||||
${shared},
|
|
||||||
url: String!,
|
|
||||||
apiKey: String!
|
|
||||||
): WalletRecvPhoenixd!
|
|
||||||
|
|
||||||
upsertWalletSendBlink(
|
|
||||||
${shared},
|
|
||||||
currency: VaultEntryInput!,
|
|
||||||
apiKey: VaultEntryInput!
|
|
||||||
): WalletSendBlink!
|
|
||||||
|
|
||||||
upsertWalletRecvBlink(
|
|
||||||
${shared},
|
|
||||||
currency: String!,
|
|
||||||
apiKey: String!
|
|
||||||
): WalletRecvBlink!
|
|
||||||
|
|
||||||
upsertWalletRecvLightningAddress(
|
|
||||||
${shared},
|
|
||||||
address: String!
|
|
||||||
): WalletRecvLightningAddress!
|
|
||||||
|
|
||||||
upsertWalletSendNWC(
|
|
||||||
${shared},
|
|
||||||
url: VaultEntryInput!
|
|
||||||
): WalletSendNWC!
|
|
||||||
|
|
||||||
upsertWalletRecvNWC(
|
|
||||||
${shared},
|
|
||||||
url: String!
|
|
||||||
): WalletRecvNWC!
|
|
||||||
|
|
||||||
upsertWalletRecvCLNRest(
|
|
||||||
${shared},
|
|
||||||
socket: String!,
|
|
||||||
rune: String!,
|
|
||||||
cert: String
|
|
||||||
): WalletRecvCLNRest!
|
|
||||||
|
|
||||||
upsertWalletRecvLNDGRPC(
|
|
||||||
${shared},
|
|
||||||
socket: String!,
|
|
||||||
macaroon: String!,
|
|
||||||
cert: String
|
|
||||||
): WalletRecvLNDGRPC!
|
|
||||||
|
|
||||||
upsertWalletSendLNC(
|
|
||||||
${shared},
|
|
||||||
pairingPhrase: VaultEntryInput!,
|
|
||||||
localKey: VaultEntryInput!,
|
|
||||||
remoteKey: VaultEntryInput!,
|
|
||||||
serverHost: VaultEntryInput!
|
|
||||||
): WalletSendLNC!
|
|
||||||
|
|
||||||
upsertWalletSendWebLN(
|
|
||||||
${shared}
|
|
||||||
): WalletSendWebLN!
|
|
||||||
|
|
||||||
# tests
|
|
||||||
testWalletRecvNWC(
|
|
||||||
url: String!
|
|
||||||
): Boolean!
|
|
||||||
|
|
||||||
testWalletRecvLightningAddress(
|
|
||||||
address: String!
|
|
||||||
): Boolean!
|
|
||||||
|
|
||||||
testWalletRecvCLNRest(
|
|
||||||
socket: String!,
|
|
||||||
rune: String!,
|
|
||||||
cert: String
|
|
||||||
): Boolean!
|
|
||||||
|
|
||||||
testWalletRecvLNDGRPC(
|
|
||||||
socket: String!,
|
|
||||||
macaroon: String!,
|
|
||||||
cert: String
|
|
||||||
): Boolean!
|
|
||||||
|
|
||||||
testWalletRecvPhoenixd(
|
|
||||||
url: String!
|
|
||||||
apiKey: String!
|
|
||||||
): Boolean!
|
|
||||||
|
|
||||||
testWalletRecvLNbits(
|
|
||||||
url: String!
|
|
||||||
apiKey: String!
|
|
||||||
): Boolean!
|
|
||||||
|
|
||||||
testWalletRecvBlink(
|
|
||||||
currency: String!
|
|
||||||
apiKey: String!
|
|
||||||
): Boolean!
|
|
||||||
|
|
||||||
# delete
|
|
||||||
removeWallet(id: ID!): Boolean
|
removeWallet(id: ID!): Boolean
|
||||||
removeWalletProtocol(id: ID!): Boolean
|
deleteWalletLogs(wallet: String): Boolean
|
||||||
|
setWalletPriority(id: ID!, priority: Int!): Boolean
|
||||||
# crypto
|
buyCredits(credits: Int!): BuyCreditsPaidAction!
|
||||||
updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean
|
|
||||||
updateKeyHash(keyHash: String!): Boolean
|
|
||||||
resetWallets(newKeyHash: String!): Boolean
|
|
||||||
disablePassphraseExport: Boolean
|
|
||||||
|
|
||||||
# settings
|
|
||||||
setWalletSettings(settings: WalletSettingsInput!): Boolean
|
|
||||||
setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean
|
|
||||||
|
|
||||||
# logs
|
|
||||||
addWalletLog(protocolId: Int, level: String!, message: String!, timestamp: Date!, invoiceId: Int): Boolean
|
|
||||||
deleteWalletLogs(protocolId: Int, debug: Boolean): Boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BuyCreditsResult {
|
type BuyCreditsResult {
|
||||||
@ -165,155 +95,15 @@ const typeDefs = gql`
|
|||||||
id: ID!
|
id: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
union WalletOrTemplate = Wallet | WalletTemplate
|
|
||||||
|
|
||||||
enum WalletStatus {
|
|
||||||
OK
|
|
||||||
WARNING
|
|
||||||
ERROR
|
|
||||||
DISABLED
|
|
||||||
}
|
|
||||||
|
|
||||||
type Wallet {
|
type Wallet {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
createdAt: Date!
|
||||||
priority: Int!
|
updatedAt: Date!
|
||||||
template: WalletTemplate!
|
type: String!
|
||||||
protocols: [WalletProtocol!]!
|
|
||||||
send: WalletStatus!
|
|
||||||
receive: WalletStatus!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletTemplate {
|
|
||||||
name: ID!
|
|
||||||
protocols: [WalletProtocolTemplate!]!
|
|
||||||
send: WalletStatus!
|
|
||||||
receive: WalletStatus!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletProtocol {
|
|
||||||
id: ID!
|
|
||||||
name: String!
|
|
||||||
send: Boolean!
|
|
||||||
enabled: Boolean!
|
enabled: Boolean!
|
||||||
config: WalletProtocolConfig!
|
priority: Int!
|
||||||
status: WalletStatus!
|
wallet: WalletDetails!
|
||||||
}
|
vaultEntries: [VaultEntry!]!
|
||||||
|
|
||||||
type WalletProtocolTemplate {
|
|
||||||
id: ID!
|
|
||||||
name: String!
|
|
||||||
send: Boolean!
|
|
||||||
}
|
|
||||||
|
|
||||||
union WalletProtocolConfig =
|
|
||||||
| WalletSendNWC
|
|
||||||
| WalletSendLNbits
|
|
||||||
| WalletSendPhoenixd
|
|
||||||
| WalletSendBlink
|
|
||||||
| WalletSendWebLN
|
|
||||||
| WalletSendLNC
|
|
||||||
| WalletRecvNWC
|
|
||||||
| WalletRecvLNbits
|
|
||||||
| WalletRecvPhoenixd
|
|
||||||
| WalletRecvBlink
|
|
||||||
| WalletRecvLightningAddress
|
|
||||||
| WalletRecvCLNRest
|
|
||||||
| WalletRecvLNDGRPC
|
|
||||||
|
|
||||||
type WalletSettings {
|
|
||||||
receiveCreditsBelowSats: Int!
|
|
||||||
sendCreditsBelowSats: Int!
|
|
||||||
autoWithdrawThreshold: Int
|
|
||||||
autoWithdrawMaxFeePercent: Float
|
|
||||||
autoWithdrawMaxFeeTotal: Int
|
|
||||||
proxyReceive: Boolean!
|
|
||||||
}
|
|
||||||
|
|
||||||
input WalletSettingsInput {
|
|
||||||
receiveCreditsBelowSats: Int!
|
|
||||||
sendCreditsBelowSats: Int!
|
|
||||||
autoWithdrawThreshold: Int!
|
|
||||||
autoWithdrawMaxFeePercent: Float!
|
|
||||||
autoWithdrawMaxFeeTotal: Int!
|
|
||||||
proxyReceive: Boolean!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletSendNWC {
|
|
||||||
id: ID!
|
|
||||||
url: VaultEntry!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletSendLNbits {
|
|
||||||
id: ID!
|
|
||||||
url: String!
|
|
||||||
apiKey: VaultEntry!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletSendPhoenixd {
|
|
||||||
id: ID!
|
|
||||||
url: String!
|
|
||||||
apiKey: VaultEntry!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletSendBlink {
|
|
||||||
id: ID!
|
|
||||||
currency: VaultEntry!
|
|
||||||
apiKey: VaultEntry!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletSendWebLN {
|
|
||||||
id: ID!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletSendLNC {
|
|
||||||
id: ID!
|
|
||||||
pairingPhrase: VaultEntry!
|
|
||||||
localKey: VaultEntry!
|
|
||||||
remoteKey: VaultEntry!
|
|
||||||
serverHost: VaultEntry!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletRecvNWC {
|
|
||||||
id: ID!
|
|
||||||
url: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletRecvLNbits {
|
|
||||||
id: ID!
|
|
||||||
url: String!
|
|
||||||
apiKey: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletRecvPhoenixd {
|
|
||||||
id: ID!
|
|
||||||
url: String!
|
|
||||||
apiKey: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletRecvBlink {
|
|
||||||
id: ID!
|
|
||||||
currency: String!
|
|
||||||
apiKey: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletRecvLightningAddress {
|
|
||||||
id: ID!
|
|
||||||
address: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletRecvCLNRest {
|
|
||||||
id: ID!
|
|
||||||
socket: String!
|
|
||||||
rune: String!
|
|
||||||
cert: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletRecvLNDGRPC {
|
|
||||||
id: ID!
|
|
||||||
socket: String!
|
|
||||||
macaroon: String!
|
|
||||||
cert: String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input AutowithdrawSettings {
|
input AutowithdrawSettings {
|
||||||
@ -322,22 +112,6 @@ const typeDefs = gql`
|
|||||||
autoWithdrawMaxFeeTotal: Int!
|
autoWithdrawMaxFeeTotal: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
input WalletEncryptionUpdate {
|
|
||||||
id: ID!
|
|
||||||
protocols: [WalletEncryptionUpdateProtocol!]!
|
|
||||||
}
|
|
||||||
|
|
||||||
input WalletEncryptionUpdateProtocol {
|
|
||||||
name: String!
|
|
||||||
send: Boolean!
|
|
||||||
config: JSONObject!
|
|
||||||
}
|
|
||||||
|
|
||||||
input WalletPriorityUpdate {
|
|
||||||
id: ID!
|
|
||||||
priority: Int!
|
|
||||||
}
|
|
||||||
|
|
||||||
type Invoice implements InvoiceOrDirect {
|
type Invoice implements InvoiceOrDirect {
|
||||||
id: ID!
|
id: ID!
|
||||||
createdAt: Date!
|
createdAt: Date!
|
||||||
@ -412,7 +186,7 @@ const typeDefs = gql`
|
|||||||
cursor: String
|
cursor: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type WalletLogs {
|
type WalletLog {
|
||||||
entries: [WalletLogEntry!]!
|
entries: [WalletLogEntry!]!
|
||||||
cursor: String
|
cursor: String
|
||||||
}
|
}
|
||||||
@ -420,25 +194,11 @@ const typeDefs = gql`
|
|||||||
type WalletLogEntry {
|
type WalletLogEntry {
|
||||||
id: ID!
|
id: ID!
|
||||||
createdAt: Date!
|
createdAt: Date!
|
||||||
wallet: Wallet
|
wallet: ID!
|
||||||
protocol: WalletProtocol
|
|
||||||
level: String!
|
level: String!
|
||||||
message: String!
|
message: String!
|
||||||
context: JSONObject
|
context: JSONObject
|
||||||
}
|
}
|
||||||
|
|
||||||
type VaultEntry {
|
|
||||||
id: ID!
|
|
||||||
iv: String!
|
|
||||||
value: String!
|
|
||||||
createdAt: Date!
|
|
||||||
updatedAt: Date!
|
|
||||||
}
|
|
||||||
|
|
||||||
input VaultEntryInput {
|
|
||||||
iv: String!
|
|
||||||
value: String!
|
|
||||||
keyHash: String!
|
|
||||||
}
|
|
||||||
`
|
`
|
||||||
export default typeDefs
|
|
||||||
|
export default gql`${injectTypeDefs(typeDefs)}`
|
||||||
|
|||||||
89
awards.csv
89
awards.csv
@ -177,84 +177,31 @@ jason-me,pr,#1857,,easy,,,,100k,rrbtc@vlt.ge,2025-02-08
|
|||||||
ed-kung,pr,#1901,#323,good-first-issue,,,,20k,simplestacker@getalby.com,2025-02-14
|
ed-kung,pr,#1901,#323,good-first-issue,,,,20k,simplestacker@getalby.com,2025-02-14
|
||||||
Scroogey-SN,pr,#1911,#1905,good-first-issue,,,1,18k,Scroogey@coinos.io,2025-03-10
|
Scroogey-SN,pr,#1911,#1905,good-first-issue,,,1,18k,Scroogey@coinos.io,2025-03-10
|
||||||
Scroogey-SN,pr,#1928,#1924,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10
|
Scroogey-SN,pr,#1928,#1924,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10
|
||||||
dtonon,issue,#1928,#1924,good-first-issue,,,,2k,tips@dtonon.com,2025-04-16
|
dtonon,issue,#1928,#1924,good-first-issue,,,,2k,???,???
|
||||||
ed-kung,pr,#1926,#1914,medium-hard,,,,500k,simplestacker@getalby.com,2025-03-10
|
ed-kung,pr,#1926,#1914,medium-hard,,,,500k,simplestacker@getalby.com,2025-03-10
|
||||||
ed-kung,issue,#1926,#1914,medium-hard,,,,50k,simplestacker@getalby.com,2025-03-10
|
ed-kung,issue,#1926,#1914,medium-hard,,,,50k,simplestacker@getalby.com,2025-03-10
|
||||||
ed-kung,pr,#1926,#1927,easy,,,,100k,simplestacker@getalby.com,2025-03-10
|
ed-kung,pr,#1926,#1927,easy,,,,100k,simplestacker@getalby.com,2025-03-10
|
||||||
ed-kung,issue,#1926,#1927,easy,,,,10k,simplestacker@getalby.com,2025-03-10
|
ed-kung,issue,#1926,#1927,easy,,,,10k,simplestacker@getalby.com,2025-03-10
|
||||||
ed-kung,issue,#1913,#1890,good-first-issue,,,,2k,simplestacker@getalby.com,2025-03-10
|
ed-kung,issue,#1913,#1890,good-first-issue,,,,2k,simplestacker@getalby.com,2025-03-10
|
||||||
Scroogey-SN,pr,#1930,#1167,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10
|
Scroogey-SN,pr,#1930,#1167,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10
|
||||||
itsrealfake,issue,#1930,#1167,good-first-issue,,,,2k,smallimagination100035@getalby.com,2025-04-02
|
itsrealfake,issue,#1930,#1167,good-first-issue,,,,2k,smallimagination100035@getalby.com,???
|
||||||
Scroogey-SN,pr,#1948,#1849,medium,urgent,,,750k,Scroogey@coinos.io,2025-03-10
|
Scroogey-SN,pr,#1948,#1849,medium,urgent,,,750k,Scroogey@coinos.io,2025-03-10
|
||||||
felipebueno,issue,#1947,#1945,good-first-issue,,,,2k,felipebueno@blink.sv,2025-03-10
|
felipebueno,issue,#1947,#1945,good-first-issue,,,,2k,felipebueno@blink.sv,2025-03-10
|
||||||
ed-kung,pr,#1952,#1951,easy,,,,100k,simplestacker@getalby.com,2025-03-10
|
ed-kung,pr,#1952,#1951,easy,,,,100k,simplestacker@getalby.com,2025-03-10
|
||||||
ed-kung,issue,#1952,#1951,easy,,,,10k,simplestacker@getalby.com,2025-03-10
|
ed-kung,issue,#1952,#1951,easy,,,,10k,simplestacker@getalby.com,2025-03-10
|
||||||
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,2025-04-02
|
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,???
|
||||||
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,me@benthecarman.com,2025-04-16
|
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,???,???
|
||||||
ed-kung,pr,#2012,#2004,easy,,,,100k,simplestacker@getalby.com,2025-04-02
|
ed-kung,pr,#2012,#2004,easy,,,,100k,simplestacker@getalby.com,???
|
||||||
ed-kung,issue,#2012,#2004,easy,,,,10k,simplestacker@getalby.com,2025-04-02
|
ed-kung,issue,#2012,#2004,easy,,,,10k,simplestacker@getalby.com,???
|
||||||
ed-kung,pr,#1993,#1982,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
|
ed-kung,pr,#1993,#1982,good-first-issue,,,,20k,simplestacker@getalby.com,???
|
||||||
rideandslide,issue,#1993,#1982,good-first-issue,,,,2k,koiora@getalby.com,2025-04-02
|
rideandslide,issue,#1993,#1982,good-first-issue,,,,2k,???,???
|
||||||
ed-kung,pr,#1972,#1254,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
|
ed-kung,pr,#1972,#1254,good-first-issue,,,,20k,simplestacker@getalby.com,???
|
||||||
SatsAllDay,issue,#1972,#1254,good-first-issue,,,,2k,weareallsatoshi@getalby.com,2025-04-02
|
SatsAllDay,issue,#1972,#1254,good-first-issue,,,,2k,weareallsatoshi@getalby.com,???
|
||||||
ed-kung,pr,#1962,#1343,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
|
ed-kung,pr,#1962,#1343,good-first-issue,,,,20k,simplestacker@getalby.com,???
|
||||||
ed-kung,pr,#1962,#1217,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
|
ed-kung,pr,#1962,#1217,good-first-issue,,,,20k,simplestacker@getalby.com,???
|
||||||
ed-kung,pr,#1962,#866,easy,,,,100k,simplestacker@getalby.com,2025-04-02
|
ed-kung,pr,#1962,#866,easy,,,,100k,simplestacker@getalby.com,???
|
||||||
felipebueno,issue,#1962,#866,easy,,,,10k,felipebueno@blink.sv,2025-04-02
|
felipebueno,issue,#1962,#866,easy,,,,10k,felipebueno@blink.sv,???
|
||||||
cointastical,issue,#1962,#1217,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
|
cointastical,issue,#1962,#1217,good-first-issue,,,,2k,cointastical@stacker.news,???
|
||||||
Scroogey-SN,pr,#1975,#1964,good-first-issue,,,,20k,Scroogey@coinos.io,2025-04-02
|
Scroogey-SN,pr,#1975,#1964,good-first-issue,,,,20k,Scroogey@coinos.io,???
|
||||||
rideandslide,issue,#1986,#1985,good-first-issue,,,,2k,koiora@getalby.com,2025-04-02
|
rideandslide,issue,#1986,#1985,good-first-issue,,,,2k,???,???
|
||||||
kristapsk,issue,#1976,#841,good-first-issue,,,,2k,kristapsk@stacker.news,2025-04-16
|
kristapsk,issue,#1976,#841,good-first-issue,,,,2k,???,???
|
||||||
ed-kung,pr,#2070,#2061,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-16
|
|
||||||
ed-kung,issue,#2070,#2061,good-first-issue,,,,2k,simplestacker@getalby.com,2025-04-16
|
|
||||||
ed-kung,pr,#2070,#2058,easy,,,,100k,simplestacker@getalby.com,2025-04-16
|
|
||||||
ed-kung,pr,#2070,#2047,medium-hard,,,,500k,simplestacker@getalby.com,2025-04-16
|
|
||||||
SouthKoreaLN,pr,#2068,#2064,good-first-issue,,,,20k,south_korea_ln@stacker.news,2025-04-16
|
|
||||||
kepford,issue,#2068,#2064,good-first-issue,,,,2k,penalwink141@minibits.cash,2025-04-16
|
|
||||||
SouthKoreaLN,pr,#2069,#1990,good-first-issue,,,,20k,south_korea_ln@stacker.news,2025-04-16
|
|
||||||
cointastical,issue,#2071,#1475,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
|
|
||||||
brymut,pr,#2082,#2051,easy,low,3,,35k,brymut@stacker.news,2025-04-16
|
|
||||||
abhiShandy,pr,#2083,#1270,good-first-issue,,,,20k,abhishandy@stacker.news,2025-04-16
|
|
||||||
cointastical,issue,#2083,#1270,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
|
|
||||||
brymut,pr,#2093,#1991,easy,,,,100k,brymut@stacker.news,2025-04-16
|
|
||||||
brymut,pr,#2100,#2090,easy,,3,,70k,brymut@stacker.news,2025-04-24
|
|
||||||
abhiShandy,pr,#2109,#1221,good-first-issue,,,,20k,abhishandy@stacker.news,2025-04-24
|
|
||||||
Gudnessuche,issue,#2109,#1221,good-first-issue,,,,2k,everythingsatoshi@getalby.com,2025-04-24
|
|
||||||
brymut,pr,#2153,#2087,easy,,,,100k,brymut@stacker.news,2025-05-13
|
|
||||||
brymut,pr,#2152,#2142,good-first-issue,,1,,18k,brymut@stacker.news,2025-05-13
|
|
||||||
m0wer,pr,#2124,#992,medium,,1,,225k,klk@stacker.news,2025-05-13
|
|
||||||
ed-kung,issue,#2072,#2043,easy,,,,10k,simplestacker@getalby.com,2025-05-15
|
|
||||||
ed-kung,helpfulness,#2072,#2043,easy,,,,10k,simplestacker@getalby.com,2025-05-15
|
|
||||||
SouthKoreaLN,pr,#2072,#2043,easy,,,,100k,south_korea_ln@stacker.news,2025-05-13
|
|
||||||
m0wer,pr,#2135,#1391,easy,,1,more difficult than planned,150k,klk@stacker.news,2025-05-13
|
|
||||||
sutt,pr,#2162,#2161,good-first-issue,,,,20k,bounty_hunter@stacker.news,2025-05-15
|
|
||||||
sutt,issue,#2162,#2161,good-first-issue,,,,2k,bounty_hunter@stacker.news,2025-05-15
|
|
||||||
brymut,pr,#2171,#2164,easy,,,,100k,brymut@stacker.news,2025-05-21
|
|
||||||
SouthKoreaLN,issue,#2171,#2164,easy,,,,10k,south_korea_ln@stacker.news,2025-05-21
|
|
||||||
brymut,pr,#2175,#2173,good-first-issue,,,,20k,brymut@stacker.news,2025-05-21
|
|
||||||
sutt,pr,#2185,#2183,easy,high,,,200k,bounty_hunter@stacker.news,2025-06-19
|
|
||||||
sutt,issue,#2185,#2183,easy,high,,,20k,bounty_hunter@stacker.news,2025-06-06
|
|
||||||
axelvyrn,advisory,#2205,GHSA-x2xp-x867-4jfc,,,,,100k,holonite@speed.app,2025-06-06
|
|
||||||
brymut,pr,#2184,#2165,easy,,,,100k,brymut@stacker.news,2025-06-06
|
|
||||||
sutt,pr,#2190,#2187,easy,,,,100k,bounty_hunter@stacker.news,2025-06-06
|
|
||||||
sutt,issue,#2190,#2187,easy,,,,10k,bounty_hunter@stacker.news,2025-06-06
|
|
||||||
sutt,pr,#2192,#2188,medium,,,,250k,bounty_hunter@stacker.news,2025-06-19
|
|
||||||
sutt,issue,#2192,#2188,medium,,,,25k,bounty_hunter@stacker.news,2025-06-19
|
|
||||||
abhiShandy,pr,#2195,#2181,good-first-issue,,1,,18k,abhishandy@stacker.news,2025-06-13
|
|
||||||
brymut,pr,#2191,#1409,medium,,2,,200k,brymut@stacker.news,2025-06-13
|
|
||||||
SatsAllDay,issue,#2191,#1409,medium,,,,20k,weareallsatoshi@getalby.com,2025-06-13
|
|
||||||
ed-kung,pr,#2217,#2039,medium,,,,250k,simplestacker@getalby.com,2025-06-18
|
|
||||||
ed-kung,issue,#2217,#2039,medium,,,,25k,simplestacker@getalby.com,2025-06-18
|
|
||||||
axelvyrn,pr,#2220,#2198,good-first-issue,,5,,10k,holonite@speed.app,2025-06-18
|
|
||||||
axelvyrn,issue,#2220,#2198,good-first-issue,,,,1k,holonite@speed.app,2025-06-18
|
|
||||||
brymut,pr,#2221,#2204,good-first-issue,,,,20k,brymut@stacker.news,2025-06-18
|
|
||||||
brymut,pr,#2235,#2233,good-first-issue,,,,20k,brymut@stacker.news,2025-06-18
|
|
||||||
brymut,pr,#2250,#2106,good-first-issue,,,,20k,brymut@stacker.news,2025-07-12
|
|
||||||
SouthKoreaLN,issue,#2267,#2164,easy,,,,10k,south_korea_ln@stacker.news,2025-07-12
|
|
||||||
pory-gone,pr,#2316,#2277,good-first-issue,,,,20k,pory@porygone.xyz,2025-08-01
|
|
||||||
brymut,pr,#2326,,good-first-issue,,,,20k,brymut@stacker.news,2025-07-31
|
|
||||||
brymut,pr,#2332,#2276,easy,,,,100k,brymut@stacker.news,2025-07-31
|
|
||||||
ed-kung,pr,#2373,#2371,good-first-issue,,,,20k,simplestacker@getalby.com,2025-07-31
|
|
||||||
ed-kung,issue,#2373,#2371,good-first-issue,,,,2k,simplestacker@getalby.com,2025-07-31
|
|
||||||
pory-gone,pr,#2381,#2370,good-first-issue,,,,20k,pory@porygone.xyz,???
|
|
||||||
pory-gone,pr,#2413,#2361,easy,,,,100k,pory@porygone.xyz,???
|
|
||||||
|
|||||||
|
695
capture/package-lock.json
generated
695
capture/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,7 +9,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.20.0",
|
"express": "^4.18.2",
|
||||||
"puppeteer": "^20.8.2"
|
"puppeteer": "^20.8.2"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export default function AccordianItem ({ header, body, className, headerColor =
|
|||||||
<Accordion defaultActiveKey={activeKey} activeKey={activeKey} onSelect={handleOnSelect}>
|
<Accordion defaultActiveKey={activeKey} activeKey={activeKey} onSelect={handleOnSelect}>
|
||||||
<ContextAwareToggle show={show} eventKey={KEY_ID} headerColor={headerColor}><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle>
|
<ContextAwareToggle show={show} eventKey={KEY_ID} headerColor={headerColor}><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle>
|
||||||
<Accordion.Collapse eventKey={KEY_ID} className={classNames('mt-2', className)}>
|
<Accordion.Collapse eventKey={KEY_ID} className={classNames('mt-2', className)}>
|
||||||
<div key={activeKey}>{body}</div>
|
<div>{body}</div>
|
||||||
</Accordion.Collapse>
|
</Accordion.Collapse>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,44 +1,165 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { USER_ID } from '@/lib/constants'
|
import * as cookie from 'cookie'
|
||||||
|
import { useMe } from '@/components/me'
|
||||||
|
import { USER_ID, SSR } from '@/lib/constants'
|
||||||
import { USER } from '@/fragments/users'
|
import { USER } from '@/fragments/users'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { UserListRow } from '@/components/user-list'
|
import { UserListRow } from '@/components/user-list'
|
||||||
import useCookie from '@/components/use-cookie'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import AddIcon from '@/svgs/add-fill.svg'
|
import AddIcon from '@/svgs/add-fill.svg'
|
||||||
|
import { MultiAuthErrorBanner } from '@/components/banners'
|
||||||
import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_LIST, MULTI_AUTH_POINTER } from '@/lib/auth'
|
import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_LIST, MULTI_AUTH_POINTER } from '@/lib/auth'
|
||||||
|
|
||||||
|
const AccountContext = createContext()
|
||||||
|
|
||||||
|
const CHECK_ERRORS_INTERVAL_MS = 5_000
|
||||||
|
|
||||||
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
|
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
|
||||||
|
|
||||||
export const nextAccount = async () => {
|
export const AccountProvider = ({ children }) => {
|
||||||
|
const [accounts, setAccounts] = useState([])
|
||||||
|
const [meAnon, setMeAnon] = useState(true)
|
||||||
|
const [errors, setErrors] = useState([])
|
||||||
|
|
||||||
|
const updateAccountsFromCookie = useCallback(() => {
|
||||||
|
const { [MULTI_AUTH_LIST]: listCookie } = cookie.parse(document.cookie)
|
||||||
|
const accounts = listCookie
|
||||||
|
? JSON.parse(b64Decode(listCookie))
|
||||||
|
: []
|
||||||
|
setAccounts(accounts)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const nextAccount = useCallback(async () => {
|
||||||
const { status } = await fetch('/api/next-account', { credentials: 'include' })
|
const { status } = await fetch('/api/next-account', { credentials: 'include' })
|
||||||
// if status is 302, this means the server was able to switch us to the next available account
|
// if status is 302, this means the server was able to switch us to the next available account
|
||||||
return status === 302
|
// and the current account was simply removed from the list of available accounts including the corresponding JWT.
|
||||||
|
const switchSuccess = status === 302
|
||||||
|
if (switchSuccess) updateAccountsFromCookie()
|
||||||
|
return switchSuccess
|
||||||
|
}, [updateAccountsFromCookie])
|
||||||
|
|
||||||
|
const checkErrors = useCallback(() => {
|
||||||
|
const {
|
||||||
|
[MULTI_AUTH_LIST]: listCookie,
|
||||||
|
[MULTI_AUTH_POINTER]: pointerCookie
|
||||||
|
} = cookie.parse(document.cookie)
|
||||||
|
|
||||||
|
const errors = []
|
||||||
|
|
||||||
|
if (!listCookie) errors.push(`${MULTI_AUTH_LIST} cookie not found`)
|
||||||
|
if (!pointerCookie) errors.push(`${MULTI_AUTH_POINTER} cookie not found`)
|
||||||
|
|
||||||
|
setErrors(errors)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (SSR) return
|
||||||
|
|
||||||
|
updateAccountsFromCookie()
|
||||||
|
|
||||||
|
const { [MULTI_AUTH_POINTER]: pointerCookie } = cookie.parse(document.cookie)
|
||||||
|
setMeAnon(pointerCookie === 'anonymous')
|
||||||
|
|
||||||
|
const interval = setInterval(checkErrors, CHECK_ERRORS_INTERVAL_MS)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [updateAccountsFromCookie, checkErrors])
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
accounts,
|
||||||
|
meAnon,
|
||||||
|
setMeAnon,
|
||||||
|
nextAccount,
|
||||||
|
multiAuthErrors: errors
|
||||||
|
}),
|
||||||
|
[accounts, meAnon, setMeAnon, nextAccount])
|
||||||
|
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAccounts = () => useContext(AccountContext)
|
||||||
|
|
||||||
|
const AccountListRow = ({ account, ...props }) => {
|
||||||
|
const { meAnon, setMeAnon } = useAccounts()
|
||||||
|
const { me, refreshMe } = useMe()
|
||||||
|
const anonRow = account.id === USER_ID.anon
|
||||||
|
const selected = (meAnon && anonRow) || Number(me?.id) === Number(account.id)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// fetch updated names and photo ids since they might have changed since we were issued the JWTs
|
||||||
|
const { data, error } = useQuery(USER,
|
||||||
|
{
|
||||||
|
variables: { id: account.id }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (error) console.error(`query for user ${account.id} failed:`, error)
|
||||||
|
|
||||||
|
const name = data?.user?.name || account.name
|
||||||
|
const photoId = data?.user?.photoId || account.photoId
|
||||||
|
|
||||||
|
const onClick = async (e) => {
|
||||||
|
// prevent navigation
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
// update pointer cookie
|
||||||
|
const options = cookieOptions({ httpOnly: false })
|
||||||
|
document.cookie = cookie.serialize(MULTI_AUTH_POINTER, anonRow ? MULTI_AUTH_ANON : account.id, options)
|
||||||
|
|
||||||
|
// update state
|
||||||
|
if (anonRow) {
|
||||||
|
// order is important to prevent flashes of no session
|
||||||
|
setMeAnon(true)
|
||||||
|
await refreshMe()
|
||||||
|
} else {
|
||||||
|
await refreshMe()
|
||||||
|
// order is important to prevent flashes of inconsistent data in switch account dialog
|
||||||
|
setMeAnon(account.id === USER_ID.anon)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reload whatever page we're on to avoid any bugs due to missing authorization etc.
|
||||||
|
router.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='d-flex flex-row'>
|
||||||
|
<UserListRow
|
||||||
|
user={{ ...account, photoId, name }}
|
||||||
|
className='d-flex align-items-center me-2'
|
||||||
|
{...props}
|
||||||
|
onNymClick={onClick}
|
||||||
|
selected={selected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SwitchAccountList () {
|
export default function SwitchAccountList () {
|
||||||
|
const { accounts, multiAuthErrors } = useAccounts()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const accounts = useAccounts()
|
|
||||||
const [pointerCookie] = useCookie(MULTI_AUTH_POINTER)
|
|
||||||
|
|
||||||
|
const hasError = multiAuthErrors.length > 0
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='my-2'>
|
||||||
|
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
|
||||||
|
<MultiAuthErrorBanner errors={multiAuthErrors} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// can't show hat since the streak is not included in the JWT payload
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='my-2'>
|
<div className='my-2'>
|
||||||
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
|
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
|
||||||
<h4 className='text-muted'>Accounts</h4>
|
<h4 className='text-muted'>Accounts</h4>
|
||||||
<AccountListRow
|
<AccountListRow account={{ id: USER_ID.anon, name: 'anon' }} showHat={false} />
|
||||||
account={{ id: USER_ID.anon, name: 'anon' }}
|
|
||||||
selected={pointerCookie === MULTI_AUTH_ANON}
|
|
||||||
showHat={false}
|
|
||||||
/>
|
|
||||||
{
|
{
|
||||||
accounts.map((account) =>
|
accounts.map((account) => <AccountListRow key={account.id} account={account} showHat={false} />)
|
||||||
<AccountListRow
|
|
||||||
key={account.id}
|
|
||||||
account={account}
|
|
||||||
selected={Number(pointerCookie) === account.id}
|
|
||||||
showHat={false}
|
|
||||||
/>)
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
@ -54,45 +175,3 @@ export default function SwitchAccountList () {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AccountListRow = ({ account, selected, ...props }) => {
|
|
||||||
const router = useRouter()
|
|
||||||
const [, setPointerCookie] = useCookie(MULTI_AUTH_POINTER)
|
|
||||||
|
|
||||||
// fetch updated names and photo ids since they might have changed since we were issued the JWTs
|
|
||||||
const { data, error } = useQuery(USER, { variables: { id: account.id } })
|
|
||||||
if (error) console.error(`query for user ${account.id} failed:`, error)
|
|
||||||
|
|
||||||
const name = data?.user?.name || account.name
|
|
||||||
const photoId = data?.user?.photoId || account.photoId
|
|
||||||
|
|
||||||
const onClick = async (e) => {
|
|
||||||
// prevent navigation
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
// update pointer cookie
|
|
||||||
const options = cookieOptions({ httpOnly: false })
|
|
||||||
const anon = account.id === USER_ID.anon
|
|
||||||
setPointerCookie(anon ? MULTI_AUTH_ANON : account.id, options)
|
|
||||||
|
|
||||||
// reload whatever page we're on to avoid any bugs due to missing authorization etc.
|
|
||||||
router.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='d-flex flex-row'>
|
|
||||||
<UserListRow
|
|
||||||
user={{ ...account, photoId, name }}
|
|
||||||
className='d-flex align-items-center me-2'
|
|
||||||
selected={selected}
|
|
||||||
{...props}
|
|
||||||
onNymClick={onClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAccounts = () => {
|
|
||||||
const [listCookie] = useCookie(MULTI_AUTH_LIST)
|
|
||||||
return listCookie ? JSON.parse(b64Decode(listCookie)) : []
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'
|
|||||||
import AccordianItem from './accordian-item'
|
import AccordianItem from './accordian-item'
|
||||||
import { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
|
import { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
|
||||||
import InputGroup from 'react-bootstrap/InputGroup'
|
import InputGroup from 'react-bootstrap/InputGroup'
|
||||||
import { BOOST_MIN, BOOST_MAX, BOOST_MULT, MAX_FORWARDS, SSR } from '@/lib/constants'
|
import { BOOST_MIN, BOOST_MULT, MAX_FORWARDS, SSR } from '@/lib/constants'
|
||||||
import { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
|
import { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
|
||||||
import Info from './info'
|
import Info from './info'
|
||||||
import { abbrNum, numWithUnits } from '@/lib/format'
|
import { abbrNum, numWithUnits } from '@/lib/format'
|
||||||
@ -37,7 +37,6 @@ export function BoostHelp () {
|
|||||||
<li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li>
|
<li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li>
|
||||||
<li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li>
|
<li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li>
|
||||||
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
|
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
|
||||||
<li>The maximum boost is {numWithUnits(BOOST_MAX, { abbreviate: false })}</li>
|
|
||||||
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker (very rare)
|
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker (very rare)
|
||||||
<ul>
|
<ul>
|
||||||
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li>
|
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li>
|
||||||
@ -198,7 +197,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
|
|||||||
for (let i = 0; i < MAX_FORWARDS; i++) {
|
for (let i = 0; i < MAX_FORWARDS; i++) {
|
||||||
['nym', 'pct'].forEach(key => {
|
['nym', 'pct'].forEach(key => {
|
||||||
const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`)
|
const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`)
|
||||||
if (value !== undefined && value !== null) {
|
if (value) {
|
||||||
formik?.setFieldValue(`forward[${i}].${key}`, value)
|
formik?.setFieldValue(`forward[${i}].${key}`, value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -269,7 +268,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
|
|||||||
emptyItem={EMPTY_FORWARD}
|
emptyItem={EMPTY_FORWARD}
|
||||||
hint={<span className='text-muted'>Forward sats to up to 5 other stackers. Any remaining sats go to you.</span>}
|
hint={<span className='text-muted'>Forward sats to up to 5 other stackers. Any remaining sats go to you.</span>}
|
||||||
>
|
>
|
||||||
{({ index, AppendColumn }) => {
|
{({ index, placeholder }) => {
|
||||||
return (
|
return (
|
||||||
<div key={index} className='d-flex flex-row'>
|
<div key={index} className='d-flex flex-row'>
|
||||||
<InputUserSuggest
|
<InputUserSuggest
|
||||||
@ -286,7 +285,6 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
|
|||||||
max={100}
|
max={100}
|
||||||
append={<InputGroup.Text className='text-monospace'>%</InputGroup.Text>}
|
append={<InputGroup.Text className='text-monospace'>%</InputGroup.Text>}
|
||||||
groupClassName={`${styles.percent} mb-0`}
|
groupClassName={`${styles.percent} mb-0`}
|
||||||
AppendColumn={AppendColumn}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,275 +0,0 @@
|
|||||||
import { useCallback, createContext, useContext, useState, useEffect } from 'react'
|
|
||||||
import Particles from 'react-particles'
|
|
||||||
import { loadFireworksPreset } from 'tsparticles-preset-fireworks'
|
|
||||||
import styles from './fireworks.module.css'
|
|
||||||
import {
|
|
||||||
rgbToHsl,
|
|
||||||
setRangeValue,
|
|
||||||
stringToRgb
|
|
||||||
} from 'tsparticles-engine'
|
|
||||||
import useDarkMode from '@/components/dark-mode'
|
|
||||||
|
|
||||||
export const FireworksContext = createContext({
|
|
||||||
strike: () => {}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const FireworksConsumer = FireworksContext.Consumer
|
|
||||||
export function useFireworks () {
|
|
||||||
const { strike } = useContext(FireworksContext)
|
|
||||||
return strike
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FireworksProvider ({ children }) {
|
|
||||||
const [cont, setCont] = useState()
|
|
||||||
const [context, setContext] = useState({ strike: () => {} })
|
|
||||||
const [darkMode] = useDarkMode()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setContext({
|
|
||||||
strike: () => {
|
|
||||||
const should = window.localStorage.getItem('lnAnimate') || 'yes'
|
|
||||||
if (should !== 'yes') return false
|
|
||||||
cont?.addEmitter(
|
|
||||||
{
|
|
||||||
direction: 'top',
|
|
||||||
life: {
|
|
||||||
count: 1,
|
|
||||||
duration: 0.1,
|
|
||||||
delay: 0.1
|
|
||||||
},
|
|
||||||
rate: {
|
|
||||||
delay: 0,
|
|
||||||
quantity: 1
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
width: 10,
|
|
||||||
height: 0
|
|
||||||
},
|
|
||||||
position: {
|
|
||||||
y: 100,
|
|
||||||
x: 50
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [cont])
|
|
||||||
|
|
||||||
const particlesLoaded = useCallback((container) => {
|
|
||||||
setCont(container)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const particlesInit = useCallback(async engine => {
|
|
||||||
// you can initiate the tsParticles instance (engine) here, adding custom shapes or presets
|
|
||||||
// this loads the tsparticles package bundle, it's the easiest method for getting everything ready
|
|
||||||
// starting from v2 you can add only the features you need reducing the bundle size
|
|
||||||
await loadFireworksPreset(engine)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FireworksContext.Provider value={context}>
|
|
||||||
<Particles
|
|
||||||
className={styles.fireworks}
|
|
||||||
init={particlesInit}
|
|
||||||
loaded={particlesLoaded}
|
|
||||||
options={darkMode ? darkOptions : lightOptions}
|
|
||||||
/>
|
|
||||||
{children}
|
|
||||||
</FireworksContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fixRange = (value, min, max) => {
|
|
||||||
const diffSMax = value.max > max ? value.max - max : 0
|
|
||||||
let res = setRangeValue(value)
|
|
||||||
|
|
||||||
if (diffSMax) {
|
|
||||||
res = setRangeValue(value.min - diffSMax, max)
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffSMin = value.min < min ? value.min : 0
|
|
||||||
|
|
||||||
if (diffSMin) {
|
|
||||||
res = setRangeValue(0, value.max + diffSMin)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
const fireworksOptions = ['#ff595e', '#ffca3a', '#8ac926', '#1982c4', '#6a4c93']
|
|
||||||
.map((color) => {
|
|
||||||
const rgb = stringToRgb(color)
|
|
||||||
|
|
||||||
if (!rgb) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const hsl = rgbToHsl(rgb)
|
|
||||||
const sRange = fixRange({ min: hsl.s - 30, max: hsl.s + 30 }, 0, 100)
|
|
||||||
const lRange = fixRange({ min: hsl.l - 30, max: hsl.l + 30 }, 0, 100)
|
|
||||||
|
|
||||||
return {
|
|
||||||
color: {
|
|
||||||
value: {
|
|
||||||
h: hsl.h,
|
|
||||||
s: sRange,
|
|
||||||
l: lRange
|
|
||||||
}
|
|
||||||
},
|
|
||||||
stroke: {
|
|
||||||
width: 0
|
|
||||||
},
|
|
||||||
number: {
|
|
||||||
value: 0
|
|
||||||
},
|
|
||||||
opacity: {
|
|
||||||
value: {
|
|
||||||
min: 0.1,
|
|
||||||
max: 1
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
enable: true,
|
|
||||||
speed: 0.7,
|
|
||||||
sync: false,
|
|
||||||
startValue: 'max',
|
|
||||||
destroy: 'min'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape: {
|
|
||||||
type: 'circle'
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
value: { min: 1, max: 2 },
|
|
||||||
animation: {
|
|
||||||
enable: true,
|
|
||||||
speed: 5,
|
|
||||||
count: 1,
|
|
||||||
sync: false,
|
|
||||||
startValue: 'min',
|
|
||||||
destroy: 'none'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
life: {
|
|
||||||
count: 1,
|
|
||||||
duration: {
|
|
||||||
value: {
|
|
||||||
min: 1,
|
|
||||||
max: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
move: {
|
|
||||||
decay: { min: 0.075, max: 0.1 },
|
|
||||||
enable: true,
|
|
||||||
gravity: {
|
|
||||||
enable: true,
|
|
||||||
inverse: false,
|
|
||||||
acceleration: 5
|
|
||||||
},
|
|
||||||
speed: { min: 5, max: 15 },
|
|
||||||
direction: 'none',
|
|
||||||
outMode: {
|
|
||||||
top: 'destroy',
|
|
||||||
default: 'bounce'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((t) => t !== undefined)
|
|
||||||
|
|
||||||
const particlesOptions = (theme) => ({
|
|
||||||
number: {
|
|
||||||
value: 0
|
|
||||||
},
|
|
||||||
destroy: {
|
|
||||||
mode: 'split',
|
|
||||||
bounds: {
|
|
||||||
top: { min: 5, max: 40 }
|
|
||||||
},
|
|
||||||
split: {
|
|
||||||
sizeOffset: false,
|
|
||||||
count: 1,
|
|
||||||
factor: {
|
|
||||||
value: 0.333333
|
|
||||||
},
|
|
||||||
rate: {
|
|
||||||
value: { min: 75, max: 150 }
|
|
||||||
},
|
|
||||||
particles: fireworksOptions
|
|
||||||
}
|
|
||||||
},
|
|
||||||
life: {
|
|
||||||
count: 1
|
|
||||||
},
|
|
||||||
shape: {
|
|
||||||
type: 'line'
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
value: {
|
|
||||||
min: 0.1,
|
|
||||||
max: 50
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
enable: true,
|
|
||||||
sync: true,
|
|
||||||
speed: 90,
|
|
||||||
startValue: 'max',
|
|
||||||
destroy: 'min'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
rotate: {
|
|
||||||
path: true
|
|
||||||
},
|
|
||||||
stroke: {
|
|
||||||
color: {
|
|
||||||
value: theme === 'dark' ? '#fff' : '#aaa'
|
|
||||||
},
|
|
||||||
width: 1
|
|
||||||
},
|
|
||||||
move: {
|
|
||||||
enable: true,
|
|
||||||
gravity: {
|
|
||||||
acceleration: 15,
|
|
||||||
enable: true,
|
|
||||||
inverse: true,
|
|
||||||
maxSpeed: 100
|
|
||||||
},
|
|
||||||
speed: {
|
|
||||||
min: 10,
|
|
||||||
max: 20
|
|
||||||
},
|
|
||||||
outModes: {
|
|
||||||
default: 'split',
|
|
||||||
top: 'none'
|
|
||||||
},
|
|
||||||
trail: {
|
|
||||||
fillColor: theme === 'dark' ? '#000' : '#f5f5f7',
|
|
||||||
enable: true,
|
|
||||||
length: 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const darkOptions = {
|
|
||||||
fullScreen: { enable: true, zIndex: -1 },
|
|
||||||
detectRetina: true,
|
|
||||||
background: {
|
|
||||||
color: '#000',
|
|
||||||
opacity: 0
|
|
||||||
},
|
|
||||||
fpsLimit: 120,
|
|
||||||
emitters: [],
|
|
||||||
particles: particlesOptions('dark')
|
|
||||||
}
|
|
||||||
|
|
||||||
const lightOptions = {
|
|
||||||
fullScreen: { enable: true, zIndex: -1 },
|
|
||||||
detectRetina: true,
|
|
||||||
background: {
|
|
||||||
color: '#fff',
|
|
||||||
opacity: 0
|
|
||||||
},
|
|
||||||
fpsLimit: 120,
|
|
||||||
emitters: [],
|
|
||||||
particles: particlesOptions('light')
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
.fireworks {
|
|
||||||
z-index: 0;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { useMe } from '@/components/me'
|
|
||||||
import { randInRange } from '@/lib/rand'
|
|
||||||
|
|
||||||
import { LightningProvider, useLightning } from './lightning'
|
|
||||||
// import { FireworksProvider, useFireworks } from './fireworks'
|
|
||||||
// import { SnowProvider, useSnow } from './snow'
|
|
||||||
|
|
||||||
const [SelectedAnimationProvider, useSelectedAnimation] = [
|
|
||||||
LightningProvider, useLightning
|
|
||||||
// FireworksProvider, useFireworks
|
|
||||||
// SnowProvider, useSnow // TODO: the snow animation doesn't seem to work anymore
|
|
||||||
]
|
|
||||||
|
|
||||||
export function AnimationProvider ({ children }) {
|
|
||||||
return (
|
|
||||||
<SelectedAnimationProvider>
|
|
||||||
<AnimationHooks>
|
|
||||||
{children}
|
|
||||||
</AnimationHooks>
|
|
||||||
</SelectedAnimationProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAnimation () {
|
|
||||||
const animate = useSelectedAnimation()
|
|
||||||
|
|
||||||
return useCallback(() => {
|
|
||||||
const should = window.localStorage.getItem('lnAnimate') || 'yes'
|
|
||||||
if (should !== 'yes') return false
|
|
||||||
animate()
|
|
||||||
return true
|
|
||||||
}, [animate])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAnimationEnabled () {
|
|
||||||
const [enabled, setEnabled] = useState(undefined)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const enabled = window.localStorage.getItem('lnAnimate') || 'yes'
|
|
||||||
setEnabled(enabled === 'yes')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toggleEnabled = useCallback(() => {
|
|
||||||
setEnabled(enabled => {
|
|
||||||
const newEnabled = !enabled
|
|
||||||
window.localStorage.setItem('lnAnimate', newEnabled ? 'yes' : 'no')
|
|
||||||
return newEnabled
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return [enabled, toggleEnabled]
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnimationHooks ({ children }) {
|
|
||||||
const { me } = useMe()
|
|
||||||
const animate = useAnimation()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (me || window.localStorage.getItem('striked') || window.localStorage.getItem('lnAnimated')) return
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
const animated = animate()
|
|
||||||
if (animated) {
|
|
||||||
window.localStorage.setItem('lnAnimated', 'yep')
|
|
||||||
}
|
|
||||||
}, randInRange(3000, 10000))
|
|
||||||
return () => clearTimeout(timeout)
|
|
||||||
}, [me?.id, animate])
|
|
||||||
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
76
components/autowithdraw-shared.js
Normal file
76
components/autowithdraw-shared.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { InputGroup } from 'react-bootstrap'
|
||||||
|
import { Input } from './form'
|
||||||
|
import { useMe } from './me'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { isNumber } from '@/lib/format'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
function autoWithdrawThreshold ({ me }) {
|
||||||
|
return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autowithdrawInitial ({ me }) {
|
||||||
|
return {
|
||||||
|
autoWithdrawThreshold: autoWithdrawThreshold({ me }),
|
||||||
|
autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1,
|
||||||
|
autoWithdrawMaxFeeTotal: isNumber(me?.privates?.autoWithdrawMaxFeeTotal) ? me?.privates?.autoWithdrawMaxFeeTotal : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutowithdrawSettings () {
|
||||||
|
const { me } = useMe()
|
||||||
|
const threshold = autoWithdrawThreshold({ me })
|
||||||
|
|
||||||
|
const [sendThreshold, setSendThreshold] = useState(Math.max(Math.floor(threshold / 10), 1))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSendThreshold(Math.max(Math.floor(threshold / 10), 1))
|
||||||
|
}, [autoWithdrawThreshold])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='my-4 border border-3 rounded'>
|
||||||
|
<div className='p-3'>
|
||||||
|
<h3 className='text-center text-muted'>desired balance</h3>
|
||||||
|
<h6 className='text-center pb-3'>applies globally to all autowithdraw methods</h6>
|
||||||
|
<Input
|
||||||
|
label='desired balance'
|
||||||
|
name='autoWithdrawThreshold'
|
||||||
|
onChange={(formik, e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
setSendThreshold(Math.max(Math.floor(value / 10), 1))
|
||||||
|
}}
|
||||||
|
hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined}
|
||||||
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<h3 className='text-center text-muted pt-3'>network fees</h3>
|
||||||
|
<h6 className='text-center pb-3'>
|
||||||
|
we'll use whichever setting is higher during{' '}
|
||||||
|
<Link
|
||||||
|
target='_blank'
|
||||||
|
href='https://docs.lightning.engineering/the-lightning-network/pathfinding'
|
||||||
|
rel='noreferrer'
|
||||||
|
>pathfinding
|
||||||
|
</Link>
|
||||||
|
</h6>
|
||||||
|
<Input
|
||||||
|
label='max fee rate'
|
||||||
|
name='autoWithdrawMaxFeePercent'
|
||||||
|
hint='max fee as percent of withdrawal amount'
|
||||||
|
append={<InputGroup.Text>%</InputGroup.Text>}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='max fee total'
|
||||||
|
name='autoWithdrawMaxFeeTotal'
|
||||||
|
hint='max fee for any withdrawal amount'
|
||||||
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -6,18 +6,12 @@ import EditImage from '@/svgs/image-edit-fill.svg'
|
|||||||
import Moon from '@/svgs/moon-fill.svg'
|
import Moon from '@/svgs/moon-fill.svg'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { FileUpload } from './file-upload'
|
import { FileUpload } from './file-upload'
|
||||||
import { gql, useMutation } from '@apollo/client'
|
|
||||||
|
|
||||||
export default function Avatar ({ onSuccess }) {
|
export default function Avatar ({ onSuccess }) {
|
||||||
const [cropPhoto] = useMutation(gql`
|
|
||||||
mutation cropPhoto($photoId: ID!, $cropData: CropData) {
|
|
||||||
cropPhoto(photoId: $photoId, cropData: $cropData)
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
const [uploading, setUploading] = useState()
|
const [uploading, setUploading] = useState()
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
|
|
||||||
const Body = ({ onClose, file, onSave }) => {
|
const Body = ({ onClose, file, upload }) => {
|
||||||
const [scale, setScale] = useState(1)
|
const [scale, setScale] = useState(1)
|
||||||
const ref = useRef()
|
const ref = useRef()
|
||||||
|
|
||||||
@ -40,21 +34,13 @@ export default function Avatar ({ onSuccess }) {
|
|||||||
/>
|
/>
|
||||||
</BootstrapForm.Group>
|
</BootstrapForm.Group>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={() => {
|
||||||
const rect = ref.current.getCroppingRect()
|
ref.current.getImageScaledToCanvas().toBlob(blob => {
|
||||||
const img = new window.Image()
|
if (blob) {
|
||||||
img.onload = async () => {
|
upload(blob)
|
||||||
const cropData = {
|
|
||||||
...rect,
|
|
||||||
originalWidth: img.width,
|
|
||||||
originalHeight: img.height,
|
|
||||||
scale
|
|
||||||
}
|
|
||||||
// upload original to S3 along with crop data
|
|
||||||
await onSave(cropData)
|
|
||||||
}
|
|
||||||
img.src = URL.createObjectURL(file)
|
|
||||||
onClose()
|
onClose()
|
||||||
|
}
|
||||||
|
}, 'image/jpeg')
|
||||||
}}
|
}}
|
||||||
>save
|
>save
|
||||||
</Button>
|
</Button>
|
||||||
@ -62,45 +48,6 @@ export default function Avatar ({ onSuccess }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const startCrop = async (file, upload) => {
|
|
||||||
return new Promise((resolve, reject) =>
|
|
||||||
showModal(onClose => (
|
|
||||||
<Body
|
|
||||||
onClose={() => {
|
|
||||||
onClose()
|
|
||||||
resolve()
|
|
||||||
}}
|
|
||||||
file={file}
|
|
||||||
onSave={async (cropData) => {
|
|
||||||
setUploading(true)
|
|
||||||
try {
|
|
||||||
// upload original to S3
|
|
||||||
const photoId = await upload(file)
|
|
||||||
|
|
||||||
// crop it
|
|
||||||
const { data } = await cropPhoto({ variables: { photoId, cropData } })
|
|
||||||
const res = await fetch(data.cropPhoto)
|
|
||||||
const blob = await res.blob()
|
|
||||||
|
|
||||||
// create a file from the blob
|
|
||||||
const croppedImage = new File([blob], 'avatar.jpg', { type: 'image/jpeg' })
|
|
||||||
|
|
||||||
// upload the imgproxy cropped image
|
|
||||||
const croppedPhotoId = await upload(croppedImage)
|
|
||||||
|
|
||||||
onSuccess?.(croppedPhotoId)
|
|
||||||
setUploading(false)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
setUploading(false)
|
|
||||||
reject(e)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileUpload
|
<FileUpload
|
||||||
allow='image/*'
|
allow='image/*'
|
||||||
@ -109,7 +56,26 @@ export default function Avatar ({ onSuccess }) {
|
|||||||
console.log(e)
|
console.log(e)
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}}
|
}}
|
||||||
onSelect={startCrop}
|
onSelect={(file, upload) => {
|
||||||
|
return new Promise((resolve, reject) =>
|
||||||
|
showModal(onClose => (
|
||||||
|
<Body
|
||||||
|
onClose={() => {
|
||||||
|
onClose()
|
||||||
|
resolve()
|
||||||
|
}}
|
||||||
|
file={file}
|
||||||
|
upload={async (blob) => {
|
||||||
|
await upload(blob)
|
||||||
|
resolve(blob)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)))
|
||||||
|
}}
|
||||||
|
onSuccess={({ id }) => {
|
||||||
|
onSuccess?.(id)
|
||||||
|
setUploading(false)
|
||||||
|
}}
|
||||||
onUpload={() => {
|
onUpload={() => {
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,14 +1,29 @@
|
|||||||
import { Fragment } from 'react'
|
|
||||||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
|
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
|
||||||
import Tooltip from 'react-bootstrap/Tooltip'
|
import Tooltip from 'react-bootstrap/Tooltip'
|
||||||
import CowboyHatIcon from '@/svgs/cowboy.svg'
|
import CowboyHatIcon from '@/svgs/cowboy.svg'
|
||||||
import AnonIcon from '@/svgs/spy-fill.svg'
|
import AnonIcon from '@/svgs/spy-fill.svg'
|
||||||
import GunIcon from '@/svgs/revolver.svg'
|
|
||||||
import HorseIcon from '@/svgs/horse.svg'
|
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import { USER_ID } from '@/lib/constants'
|
import { USER_ID } from '@/lib/constants'
|
||||||
|
import GunIcon from '@/svgs/revolver.svg'
|
||||||
|
import HorseIcon from '@/svgs/horse.svg'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
const BADGES = [
|
||||||
|
{
|
||||||
|
icon: CowboyHatIcon,
|
||||||
|
streakName: 'streak'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: HorseIcon,
|
||||||
|
streakName: 'horseStreak'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GunIcon,
|
||||||
|
streakName: 'gunStreak',
|
||||||
|
sizeDelta: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
export default function Badges ({ user, badge, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) {
|
export default function Badges ({ user, badge, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) {
|
||||||
if (!user || Number(user.id) === USER_ID.ad) return null
|
if (!user || Number(user.id) === USER_ID.ad) return null
|
||||||
if (Number(user.id) === USER_ID.anon) {
|
if (Number(user.id) === USER_ID.anon) {
|
||||||
@ -19,43 +34,14 @@ export default function Badges ({ user, badge, className = 'ms-1', badgeClassNam
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const badges = []
|
|
||||||
|
|
||||||
const streak = user.optional.streak
|
|
||||||
if (streak !== null) {
|
|
||||||
badges.push({
|
|
||||||
icon: CowboyHatIcon,
|
|
||||||
overlayText: streak
|
|
||||||
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
|
|
||||||
: 'new'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.optional.hasRecvWallet) {
|
|
||||||
badges.push({
|
|
||||||
icon: HorseIcon,
|
|
||||||
overlayText: 'can receive sats'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.optional.hasSendWallet) {
|
|
||||||
badges.push({
|
|
||||||
icon: GunIcon,
|
|
||||||
sizeDelta: 2,
|
|
||||||
overlayText: 'can send sats'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (badges.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={className}>
|
<span className={className}>
|
||||||
{badges.map(({ icon, overlayText, sizeDelta }, i) => (
|
{BADGES.map(({ icon, streakName, sizeDelta }, i) => (
|
||||||
<SNBadge
|
<SNBadge
|
||||||
key={i}
|
key={streakName}
|
||||||
user={user}
|
user={user}
|
||||||
badge={badge}
|
badge={badge}
|
||||||
overlayText={overlayText}
|
streakName={streakName}
|
||||||
badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
|
badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
|
||||||
IconForBadge={icon}
|
IconForBadge={icon}
|
||||||
height={height}
|
height={height}
|
||||||
@ -67,19 +53,20 @@ export default function Badges ({ user, badge, className = 'ms-1', badgeClassNam
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SNBadge ({ user, badge, overlayText, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
|
function SNBadge ({ user, badge, streakName, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
|
||||||
let Wrapper = Fragment
|
const streak = user.optional[streakName]
|
||||||
|
if (streak === null) {
|
||||||
if (overlayText) {
|
return null
|
||||||
Wrapper = ({ children }) => (
|
|
||||||
<BadgeTooltip overlayText={overlayText}>{children}</BadgeTooltip>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<BadgeTooltip
|
||||||
|
overlayText={streak
|
||||||
|
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
|
||||||
|
: 'new'}
|
||||||
|
>
|
||||||
<span><IconForBadge className={badgeClassName} height={height + sizeDelta} width={width + sizeDelta} /></span>
|
<span><IconForBadge className={badgeClassName} height={height + sizeDelta} width={width + sizeDelta} /></span>
|
||||||
</Wrapper>
|
</BadgeTooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { useMe } from '@/components/me'
|
|||||||
import { useMutation } from '@apollo/client'
|
import { useMutation } from '@apollo/client'
|
||||||
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
|
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
|
||||||
import { useToast } from '@/components/toast'
|
import { useToast } from '@/components/toast'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import AccordianItem from '@/components/accordian-item'
|
||||||
|
|
||||||
export function WelcomeBanner ({ Banner }) {
|
export function WelcomeBanner ({ Banner }) {
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
@ -99,6 +101,22 @@ export function MadnessBanner ({ handleClose }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function WalletSecurityBanner ({ isActive }) {
|
||||||
|
return (
|
||||||
|
<Alert className={styles.banner} key='info' variant='warning'>
|
||||||
|
<Alert.Heading>
|
||||||
|
Gunslingin' Safety Tips
|
||||||
|
</Alert.Heading>
|
||||||
|
<p className='mb-3 line-height-md'>
|
||||||
|
Listen up, pardner! Put a limit on yer spendin' wallet or hook up a wallet that's only for Stacker News. It'll keep them varmints from cleanin' out yer whole goldmine if they rustle up yer wallet.
|
||||||
|
</p>
|
||||||
|
<p className='line-height-md'>
|
||||||
|
Your spending wallet's credentials are never sent to our servers in plain text. To sync across devices, <Alert.Link as={Link} href='/settings/passphrase'>enable device sync in your settings</Alert.Link>.
|
||||||
|
</p>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthBanner () {
|
export function AuthBanner () {
|
||||||
return (
|
return (
|
||||||
<Alert className={`${styles.banner} mt-0`} key='info' variant='danger'>
|
<Alert className={`${styles.banner} mt-0`} key='info' variant='danger'>
|
||||||
@ -106,3 +124,24 @@ export function AuthBanner () {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MultiAuthErrorBanner ({ errors }) {
|
||||||
|
return (
|
||||||
|
<Alert className={styles.banner} key='info' variant='danger'>
|
||||||
|
<div className='fw-bold mb-3'>Account switching is currently unavailable</div>
|
||||||
|
<AccordianItem
|
||||||
|
className='my-3'
|
||||||
|
header='We have detected the following issues:'
|
||||||
|
headerColor='var(--bs-danger-text-emphasis)'
|
||||||
|
body={
|
||||||
|
<ul>
|
||||||
|
{errors.map((err, i) => (
|
||||||
|
<li key={i}>{err}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className='mt-3'>To resolve these issues, please sign out and sign in again.</div>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -53,19 +53,12 @@ function useArrowKeys ({ moveLeft, moveRight }) {
|
|||||||
}, [onKeyDown])
|
}, [onKeyDown])
|
||||||
}
|
}
|
||||||
|
|
||||||
function Carousel ({ close, mediaArr, src, setOptions }) {
|
export default function Carousel ({ close, mediaArr, src, originalSrc, setOptions }) {
|
||||||
const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src))
|
const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src))
|
||||||
const [currentSrc, canGoLeft, canGoRight] = useMemo(() => {
|
const [currentSrc, canGoLeft, canGoRight] = useMemo(() => {
|
||||||
return [mediaArr[index][0], index > 0, index < mediaArr.length - 1]
|
return [mediaArr[index][0], index > 0, index < mediaArr.length - 1]
|
||||||
}, [mediaArr, index])
|
}, [mediaArr, index])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (index === -1) return
|
|
||||||
setOptions({
|
|
||||||
overflow: <CarouselOverflow {...mediaArr[index][1]} />
|
|
||||||
})
|
|
||||||
}, [index, mediaArr, setOptions])
|
|
||||||
|
|
||||||
const moveLeft = useCallback(() => {
|
const moveLeft = useCallback(() => {
|
||||||
setIndex(i => Math.max(0, i - 1))
|
setIndex(i => Math.max(0, i - 1))
|
||||||
}, [setIndex])
|
}, [setIndex])
|
||||||
@ -121,15 +114,15 @@ export function CarouselProvider ({ children }) {
|
|||||||
fullScreen: true,
|
fullScreen: true,
|
||||||
overflow: <CarouselOverflow {...media.current.get(src)} />
|
overflow: <CarouselOverflow {...media.current.get(src)} />
|
||||||
})
|
})
|
||||||
}, [showModal])
|
}, [showModal, media.current])
|
||||||
|
|
||||||
const addMedia = useCallback(({ src, originalSrc, rel }) => {
|
const addMedia = useCallback(({ src, originalSrc, rel }) => {
|
||||||
media.current.set(src, { src, originalSrc, rel })
|
media.current.set(src, { src, originalSrc, rel })
|
||||||
}, [])
|
}, [media.current])
|
||||||
|
|
||||||
const removeMedia = useCallback((src) => {
|
const removeMedia = useCallback((src) => {
|
||||||
media.current.delete(src)
|
media.current.delete(src)
|
||||||
}, [])
|
}, [media.current])
|
||||||
|
|
||||||
const value = useMemo(() => ({ showCarousel, addMedia, removeMedia }), [showCarousel, addMedia, removeMedia])
|
const value = useMemo(() => ({ showCarousel, addMedia, removeMedia }), [showCarousel, addMedia, removeMedia])
|
||||||
return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider>
|
return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider>
|
||||||
|
|||||||
@ -96,7 +96,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Comment ({
|
export default function Comment ({
|
||||||
item, children, replyOpen, includeParent, topLevel, rootLastCommentAt,
|
item, children, replyOpen, includeParent, topLevel,
|
||||||
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
|
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
|
||||||
}) {
|
}) {
|
||||||
const [edit, setEdit] = useState()
|
const [edit, setEdit] = useState()
|
||||||
@ -114,17 +114,6 @@ export default function Comment ({
|
|||||||
|
|
||||||
const { cache } = useApolloClient()
|
const { cache } = useApolloClient()
|
||||||
|
|
||||||
const unsetOutline = () => {
|
|
||||||
if (!ref.current) return
|
|
||||||
const hasOutline = ref.current.classList.contains('outline-new-comment') || ref.current.classList.contains('outline-new-injected-comment')
|
|
||||||
const hasOutlineUnset = ref.current.classList.contains('outline-new-comment-unset')
|
|
||||||
|
|
||||||
// don't try to unset the outline if the comment is not outlined or we already unset the outline
|
|
||||||
if (hasOutline && !hasOutlineUnset) {
|
|
||||||
ref.current.classList.add('outline-new-comment-unset')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const comment = cache.readFragment({
|
const comment = cache.readFragment({
|
||||||
id: `Item:${router.query.commentId}`,
|
id: `Item:${router.query.commentId}`,
|
||||||
@ -151,29 +140,12 @@ export default function Comment ({
|
|||||||
}, [item.id, cache, router.query.commentId])
|
}, [item.id, cache, router.query.commentId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (me?.id === item.user?.id) return
|
if (router.query.commentsViewedAt &&
|
||||||
|
me?.id !== item.user?.id &&
|
||||||
const itemCreatedAt = new Date(item.createdAt).getTime()
|
new Date(item.createdAt).getTime() > router.query.commentsViewedAt) {
|
||||||
// it's a new comment if it was created after the last comment was viewed
|
|
||||||
// or, in the case of live comments, after the last comment was created
|
|
||||||
const isNewComment = (router.query.commentsViewedAt && itemCreatedAt > router.query.commentsViewedAt) ||
|
|
||||||
(rootLastCommentAt && itemCreatedAt > new Date(rootLastCommentAt).getTime())
|
|
||||||
if (!isNewComment) return
|
|
||||||
|
|
||||||
if (item.injected) {
|
|
||||||
// newly injected comments (item.injected) have to use a different class to outline every new comment
|
|
||||||
ref.current.classList.add('outline-new-injected-comment')
|
|
||||||
|
|
||||||
// wait for the injection animation to end before removing its class
|
|
||||||
ref.current.addEventListener('animationend', () => {
|
|
||||||
ref.current.classList.remove(styles.injectedComment)
|
|
||||||
}, { once: true })
|
|
||||||
// animate the live comment injection
|
|
||||||
ref.current.classList.add(styles.injectedComment)
|
|
||||||
} else {
|
|
||||||
ref.current.classList.add('outline-new-comment')
|
ref.current.classList.add('outline-new-comment')
|
||||||
}
|
}
|
||||||
}, [item.id, rootLastCommentAt])
|
}, [item.id])
|
||||||
|
|
||||||
const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0)
|
const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0)
|
||||||
// Don't show OP badge when anon user comments on anon user posts
|
// Don't show OP badge when anon user comments on anon user posts
|
||||||
@ -187,19 +159,17 @@ export default function Comment ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
|
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
|
||||||
onMouseEnter={unsetOutline}
|
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
|
||||||
onTouchStart={unsetOutline}
|
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
|
||||||
>
|
>
|
||||||
<div className={`${itemStyles.item} ${styles.item}`}>
|
<div className={`${itemStyles.item} ${styles.item}`}>
|
||||||
{item.outlawed && !me?.privates?.wildWestMode
|
{item.outlawed && !me?.privates?.wildWestMode
|
||||||
? <Skull className={styles.dontLike} width={24} height={24} />
|
? <Skull className={styles.dontLike} width={24} height={24} />
|
||||||
: pin
|
|
||||||
? <Pin width={22} height={22} className={styles.pin} />
|
|
||||||
: item.mine
|
: item.mine
|
||||||
? <Boost item={item} className={styles.upvote} />
|
? <Boost item={item} className={styles.upvote} />
|
||||||
: item.meDontLikeSats > item.meSats
|
: item.meDontLikeSats > item.meSats
|
||||||
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||||
: <UpVote item={item} className={styles.upvote} collapsed={collapse === 'yep'} />}
|
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} collapsed={collapse === 'yep'} />}
|
||||||
<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'
|
||||||
@ -212,7 +182,6 @@ export default function Comment ({
|
|||||||
>reply from someone you muted
|
>reply from someone you muted
|
||||||
</span>)
|
</span>)
|
||||||
: <ItemInfo
|
: <ItemInfo
|
||||||
full={topLevel}
|
|
||||||
item={item}
|
item={item}
|
||||||
commentsText='replies'
|
commentsText='replies'
|
||||||
commentTextSingular='reply'
|
commentTextSingular='reply'
|
||||||
@ -280,7 +249,7 @@ export default function Comment ({
|
|||||||
</div>
|
</div>
|
||||||
{collapse !== 'yep' && (
|
{collapse !== 'yep' && (
|
||||||
bottomedOut
|
bottomedOut
|
||||||
? <div className={styles.children}><div className={classNames(styles.comment, 'mt-3 pb-2')}><ViewMoreReplies item={item} threadContext /></div></div>
|
? <div className={styles.children}><div className={classNames(styles.comment, 'mt-3')}><ReplyOnAnotherPage item={item} /></div></div>
|
||||||
: (
|
: (
|
||||||
<div className={styles.children}>
|
<div className={styles.children}>
|
||||||
{item.outlawed && !me?.privates?.wildWestMode
|
{item.outlawed && !me?.privates?.wildWestMode
|
||||||
@ -295,13 +264,9 @@ export default function Comment ({
|
|||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
{item.comments.comments.map((item) => (
|
{item.comments.comments.map((item) => (
|
||||||
<Comment depth={depth + 1} key={item.id} item={item} rootLastCommentAt={rootLastCommentAt} />
|
<Comment depth={depth + 1} key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
{item.comments.comments.length < item.nDirectComments && (
|
{item.comments.comments.length < item.nDirectComments && <ViewAllReplies id={item.id} nhas={item.ncomments} />}
|
||||||
<div className={`d-block ${styles.comment} pb-2 ps-3`}>
|
|
||||||
<ViewMoreReplies item={item} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
@ -314,24 +279,29 @@ export default function Comment ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ViewMoreReplies ({ item, threadContext = false }) {
|
export function ViewAllReplies ({ id, nshown, nhas }) {
|
||||||
const root = useRoot()
|
const text = `view all ${nhas} replies`
|
||||||
const id = threadContext ? commentSubTreeRootId(item, root) : item.id
|
|
||||||
|
|
||||||
// if threadContext is true, we travel to some comments before the current comment, focusing on the comment itself
|
|
||||||
// otherwise, we directly navigate to the comment
|
|
||||||
const href = `/items/${id}` + (threadContext ? `?commentId=${item.id}` : '')
|
|
||||||
|
|
||||||
const text = threadContext && item.ncomments === 0
|
|
||||||
? 'reply on another page'
|
|
||||||
: `view all ${item.ncomments} replies`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<div className={`d-block fw-bold ${styles.comment} pb-2 ps-3`}>
|
||||||
href={href}
|
<Link href={`/items/${id}`} as={`/items/${id}`} className='text-muted'>
|
||||||
as={`/items/${id}`}
|
{text}
|
||||||
className='fw-bold d-flex align-items-center gap-2 text-muted'
|
</Link>
|
||||||
>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReplyOnAnotherPage ({ item }) {
|
||||||
|
const root = useRoot()
|
||||||
|
const rootId = commentSubTreeRootId(item, root)
|
||||||
|
|
||||||
|
let text = 'reply on another page'
|
||||||
|
if (item.ncomments > 0) {
|
||||||
|
text = `view all ${item.ncomments} replies`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/items/${rootId}?commentId=${item.id}`} as={`/items/${rootId}`} className='d-block pb-2 fw-bold text-muted'>
|
||||||
{text}
|
{text}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -136,35 +136,3 @@
|
|||||||
.comment:has(.comment) + .comment{
|
.comment:has(.comment) + .comment{
|
||||||
padding-top: .5rem;
|
padding-top: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.newCommentDot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--bs-primary);
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
background-color: #80d3ff;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
background-color: #007cbe;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-color: #80d3ff;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.injectedComment {
|
|
||||||
animation: fadeIn 0.5s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { defaultCommentSort } from '@/lib/item'
|
|||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import MoreFooter from './more-footer'
|
import MoreFooter from './more-footer'
|
||||||
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
|
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
|
||||||
import useLiveComments from './use-live-comments'
|
|
||||||
|
|
||||||
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -65,13 +64,10 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
|
|||||||
|
|
||||||
export default function Comments ({
|
export default function Comments ({
|
||||||
parentId, pinned, bio, parentCreatedAt,
|
parentId, pinned, bio, parentCreatedAt,
|
||||||
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, lastCommentAt, item, ...props
|
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, ...props
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// fetch new comments that arrived after the lastCommentAt, and update the item.comments field in cache
|
|
||||||
useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort)
|
|
||||||
|
|
||||||
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
|
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -94,11 +90,11 @@ export default function Comments ({
|
|||||||
: null}
|
: null}
|
||||||
{pins.map(item => (
|
{pins.map(item => (
|
||||||
<Fragment key={item.id}>
|
<Fragment key={item.id}>
|
||||||
<Comment depth={1} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin />
|
<Comment depth={1} item={item} {...props} pin />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{comments.filter(({ position }) => !position).map(item => (
|
{comments.filter(({ position }) => !position).map(item => (
|
||||||
<Comment depth={1} key={item.id} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} />
|
<Comment depth={1} key={item.id} item={item} {...props} />
|
||||||
))}
|
))}
|
||||||
{ncomments > FULL_COMMENTS_THRESHOLD &&
|
{ncomments > FULL_COMMENTS_THRESHOLD &&
|
||||||
<MoreFooter
|
<MoreFooter
|
||||||
|
|||||||
@ -125,7 +125,6 @@ const Embed = memo(function Embed ({ src, provider, id, meta, className, topLeve
|
|||||||
// This Twitter embed could use similar logic to the video embeds below
|
// This Twitter embed could use similar logic to the video embeds below
|
||||||
if (provider === 'twitter') {
|
if (provider === 'twitter') {
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div className={classNames(styles.twitterContainer, !show && styles.twitterContained, className)}>
|
<div className={classNames(styles.twitterContainer, !show && styles.twitterContained, className)}>
|
||||||
<TwitterTweetEmbed
|
<TwitterTweetEmbed
|
||||||
tweetId={id}
|
tweetId={id}
|
||||||
@ -139,7 +138,6 @@ const Embed = memo(function Embed ({ src, provider, id, meta, className, topLeve
|
|||||||
show full tweet
|
show full tweet
|
||||||
</Button>}
|
</Button>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { StaticLayout } from './layout'
|
|||||||
import styles from '@/styles/error.module.css'
|
import styles from '@/styles/error.module.css'
|
||||||
import Image from 'react-bootstrap/Image'
|
import Image from 'react-bootstrap/Image'
|
||||||
import copy from 'clipboard-copy'
|
import copy from 'clipboard-copy'
|
||||||
|
import { LoggerContext } from './logger'
|
||||||
import Button from 'react-bootstrap/Button'
|
import Button from 'react-bootstrap/Button'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { decodeMinifiedStackTrace } from '@/lib/stacktrace'
|
import { decodeMinifiedStackTrace } from '@/lib/stacktrace'
|
||||||
@ -35,6 +36,8 @@ class ErrorBoundary extends Component {
|
|||||||
// You can use your own error logging service here
|
// You can use your own error logging service here
|
||||||
console.log({ error, errorInfo })
|
console.log({ error, errorInfo })
|
||||||
this.setState({ errorInfo })
|
this.setState({ errorInfo })
|
||||||
|
const logger = this.context
|
||||||
|
logger?.error(this.getErrorDetails())
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
@ -44,7 +47,7 @@ class ErrorBoundary extends Component {
|
|||||||
const errorDetails = this.getErrorDetails()
|
const errorDetails = this.getErrorDetails()
|
||||||
return (
|
return (
|
||||||
<StaticLayout footer={false}>
|
<StaticLayout footer={false}>
|
||||||
<Image width='500' height='375' className='rounded-1 shadow-sm' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.webp`} fluid />
|
<Image width='500' height='375' className='rounded-1 shadow-sm' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.gif`} fluid />
|
||||||
<h1 className={styles.status} style={{ fontSize: '48px' }}>something went wrong</h1>
|
<h1 className={styles.status} style={{ fontSize: '48px' }}>something went wrong</h1>
|
||||||
{this.state.error && <CopyErrorButton errorDetails={errorDetails} />}
|
{this.state.error && <CopyErrorButton errorDetails={errorDetails} />}
|
||||||
</StaticLayout>
|
</StaticLayout>
|
||||||
@ -56,6 +59,8 @@ class ErrorBoundary extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ErrorBoundary.contextType = LoggerContext
|
||||||
|
|
||||||
export default ErrorBoundary
|
export default ErrorBoundary
|
||||||
|
|
||||||
// This button is a functional component so we can use `useToast` hook, which
|
// This button is a functional component so we can use `useToast` hook, which
|
||||||
|
|||||||
@ -12,10 +12,10 @@ import No from '@/svgs/no.svg'
|
|||||||
import Bolt from '@/svgs/bolt.svg'
|
import Bolt from '@/svgs/bolt.svg'
|
||||||
import Amboss from '@/svgs/amboss.svg'
|
import Amboss from '@/svgs/amboss.svg'
|
||||||
import Mempool from '@/svgs/bimi.svg'
|
import Mempool from '@/svgs/bimi.svg'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import Rewards from './footer-rewards'
|
import Rewards from './footer-rewards'
|
||||||
import useDarkMode from './dark-mode'
|
import useDarkMode from './dark-mode'
|
||||||
import ActionTooltip from './action-tooltip'
|
import ActionTooltip from './action-tooltip'
|
||||||
import { useAnimationEnabled } from '@/components/animation'
|
|
||||||
|
|
||||||
const RssPopover = (
|
const RssPopover = (
|
||||||
<Popover>
|
<Popover>
|
||||||
@ -53,7 +53,6 @@ const RssPopover = (
|
|||||||
const SocialsPopover = (
|
const SocialsPopover = (
|
||||||
<Popover>
|
<Popover>
|
||||||
<Popover.Body style={{ fontWeight: 500, fontSize: '.9rem' }}>
|
<Popover.Body style={{ fontWeight: 500, fontSize: '.9rem' }}>
|
||||||
<div className='d-flex justify-content-center'>
|
|
||||||
<a
|
<a
|
||||||
href='https://njump.me/npub1jfujw6llhq7wuvu5detycdsq5v5yqf56sgrdq8wlgrryx2a2p09svwm0gx' className='nav-link p-0 d-inline-flex'
|
href='https://njump.me/npub1jfujw6llhq7wuvu5detycdsq5v5yqf56sgrdq8wlgrryx2a2p09svwm0gx' className='nav-link p-0 d-inline-flex'
|
||||||
target='_blank' rel='noreferrer'
|
target='_blank' rel='noreferrer'
|
||||||
@ -74,22 +73,13 @@ const SocialsPopover = (
|
|||||||
>
|
>
|
||||||
youtube
|
youtube
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<span className='mx-2 text-muted'> \ </span>
|
||||||
<div className='d-flex justify-content-center'>
|
|
||||||
<a
|
<a
|
||||||
href='https://www.fountain.fm/show/Mg1AWuvkeZSFhsJZ3BW2' className='nav-link p-0 d-inline-flex'
|
href='https://www.fountain.fm/show/Mg1AWuvkeZSFhsJZ3BW2' className='nav-link p-0 d-inline-flex'
|
||||||
target='_blank' rel='noreferrer'
|
target='_blank' rel='noreferrer'
|
||||||
>
|
>
|
||||||
pod
|
pod
|
||||||
</a>
|
</a>
|
||||||
<span className='mx-2 text-muted'> \ </span>
|
|
||||||
<a
|
|
||||||
href='https://www.plebpoet.com/zines.html' className='nav-link p-0 d-inline-flex'
|
|
||||||
target='_blank' rel='noreferrer'
|
|
||||||
>
|
|
||||||
zines
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</Popover.Body>
|
</Popover.Body>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
@ -145,10 +135,24 @@ const LegalPopover = (
|
|||||||
export default function Footer ({ links = true }) {
|
export default function Footer ({ links = true }) {
|
||||||
const [darkMode, darkModeToggle] = useDarkMode()
|
const [darkMode, darkModeToggle] = useDarkMode()
|
||||||
|
|
||||||
const [animationEnabled, toggleAnimation] = useAnimationEnabled()
|
const [lightning, setLightning] = useState(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLightning(window.localStorage.getItem('lnAnimate') || 'yes')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleLightning = () => {
|
||||||
|
if (lightning === 'yes') {
|
||||||
|
window.localStorage.setItem('lnAnimate', 'no')
|
||||||
|
setLightning('no')
|
||||||
|
} else {
|
||||||
|
window.localStorage.setItem('lnAnimate', 'yes')
|
||||||
|
setLightning('yes')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DarkModeIcon = darkMode ? Sun : Moon
|
const DarkModeIcon = darkMode ? Sun : Moon
|
||||||
const LnIcon = animationEnabled ? No : Bolt
|
const LnIcon = lightning === 'yes' ? No : Bolt
|
||||||
|
|
||||||
const version = process.env.NEXT_PUBLIC_COMMIT_HASH
|
const version = process.env.NEXT_PUBLIC_COMMIT_HASH
|
||||||
|
|
||||||
@ -161,8 +165,8 @@ export default function Footer ({ links = true }) {
|
|||||||
<ActionTooltip notForm overlayText={`${darkMode ? 'disable' : 'enable'} dark mode`}>
|
<ActionTooltip notForm overlayText={`${darkMode ? 'disable' : 'enable'} dark mode`}>
|
||||||
<DarkModeIcon onClick={darkModeToggle} width={20} height={20} className='fill-grey theme' suppressHydrationWarning />
|
<DarkModeIcon onClick={darkModeToggle} width={20} height={20} className='fill-grey theme' suppressHydrationWarning />
|
||||||
</ActionTooltip>
|
</ActionTooltip>
|
||||||
<ActionTooltip notForm overlayText={`${animationEnabled ? 'disable' : 'enable'} lightning animations`}>
|
<ActionTooltip notForm overlayText={`${lightning === 'yes' ? 'disable' : 'enable'} lightning animations`}>
|
||||||
<LnIcon onClick={toggleAnimation} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
|
<LnIcon onClick={toggleLightning} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
|
||||||
</ActionTooltip>
|
</ActionTooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className='mb-0' style={{ fontWeight: 500 }}>
|
<div className='mb-0' style={{ fontWeight: 500 }}>
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import AddIcon from '@/svgs/add-fill.svg'
|
|||||||
import CloseIcon from '@/svgs/close-line.svg'
|
import CloseIcon from '@/svgs/close-line.svg'
|
||||||
import { gql, useLazyQuery } from '@apollo/client'
|
import { gql, useLazyQuery } from '@apollo/client'
|
||||||
import { USER_SUGGESTIONS } from '@/fragments/users'
|
import { USER_SUGGESTIONS } from '@/fragments/users'
|
||||||
import { SUB_SUGGESTIONS } from '@/fragments/subs'
|
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
import TextareaAutosize from 'react-textarea-autosize'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
@ -34,9 +33,12 @@ import Info from './info'
|
|||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import Clipboard from '@/svgs/clipboard-line.svg'
|
import Clipboard from '@/svgs/clipboard-line.svg'
|
||||||
|
import QrIcon from '@/svgs/qr-code-line.svg'
|
||||||
import QrScanIcon from '@/svgs/qr-scan-line.svg'
|
import QrScanIcon from '@/svgs/qr-scan-line.svg'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
|
import { qrImageSettings } from './qr'
|
||||||
import { useIsClient } from './use-client'
|
import { useIsClient } from './use-client'
|
||||||
import PageLoading from './page-loading'
|
import PageLoading from './page-loading'
|
||||||
|
|
||||||
@ -75,7 +77,7 @@ export function SubmitButton ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CopyButton ({ value, icon, ...props }) {
|
function CopyButton ({ value, icon, ...props }) {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
@ -137,174 +139,6 @@ function setNativeValue (textarea, value) {
|
|||||||
textarea.dispatchEvent(new Event('input', { bubbles: true, value }))
|
textarea.dispatchEvent(new Event('input', { bubbles: true, value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function useEntityAutocomplete ({
|
|
||||||
prefix,
|
|
||||||
meta,
|
|
||||||
helpers,
|
|
||||||
innerRef,
|
|
||||||
setSelectionRange,
|
|
||||||
SuggestComponent
|
|
||||||
}) {
|
|
||||||
const [entityData, setEntityData] = useState()
|
|
||||||
|
|
||||||
const handleSelect = useCallback((name) => {
|
|
||||||
if (entityData?.start === undefined || entityData?.end === undefined) return
|
|
||||||
const { start, end } = entityData
|
|
||||||
setEntityData(undefined)
|
|
||||||
const first = `${meta?.value.substring(0, start)}${prefix}${name}`
|
|
||||||
const second = meta?.value.substring(end)
|
|
||||||
const updatedValue = `${first}${second}`
|
|
||||||
helpers.setValue(updatedValue)
|
|
||||||
setSelectionRange({ start: first.length, end: first.length })
|
|
||||||
innerRef.current.focus()
|
|
||||||
}, [entityData, meta?.value, helpers, prefix, setSelectionRange, innerRef])
|
|
||||||
|
|
||||||
const handleTextChange = useCallback((e) => {
|
|
||||||
const { value, selectionStart } = e.target
|
|
||||||
if (!value || selectionStart === undefined) {
|
|
||||||
setEntityData(undefined)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let priorSpace = -1
|
|
||||||
for (let i = selectionStart - 1; i >= 0; i--) {
|
|
||||||
if (/[^\w@~]/.test(value[i])) {
|
|
||||||
priorSpace = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextSpace = value.length
|
|
||||||
for (let i = selectionStart; i <= value.length; i++) {
|
|
||||||
if (/[^\w]/.test(value[i])) {
|
|
||||||
nextSpace = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSegment = value.substring(priorSpace + 1, nextSpace)
|
|
||||||
const regexPattern = new RegExp(`^\\${prefix}\\w*$`)
|
|
||||||
|
|
||||||
if (regexPattern.test(currentSegment)) {
|
|
||||||
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
|
|
||||||
setEntityData({
|
|
||||||
query: currentSegment,
|
|
||||||
start: priorSpace + 1,
|
|
||||||
end: nextSpace,
|
|
||||||
style: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
|
|
||||||
left: `${left}px`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
setEntityData(undefined)
|
|
||||||
return false
|
|
||||||
}, [prefix])
|
|
||||||
|
|
||||||
// Return a function that takes a render prop instead of directly returning the component
|
|
||||||
return {
|
|
||||||
entityData,
|
|
||||||
handleSelect,
|
|
||||||
handleTextChange,
|
|
||||||
renderSuggest: (renderProps) => {
|
|
||||||
if (!entityData) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SuggestComponent
|
|
||||||
query={entityData?.query}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
dropdownStyle={entityData?.style}
|
|
||||||
>
|
|
||||||
{renderProps}
|
|
||||||
</SuggestComponent>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDualAutocomplete ({ meta, helpers, innerRef, setSelectionRange }) {
|
|
||||||
const userAutocomplete = useEntityAutocomplete({
|
|
||||||
prefix: '@',
|
|
||||||
meta,
|
|
||||||
helpers,
|
|
||||||
innerRef,
|
|
||||||
setSelectionRange,
|
|
||||||
SuggestComponent: UserSuggest
|
|
||||||
})
|
|
||||||
|
|
||||||
const territoryAutocomplete = useEntityAutocomplete({
|
|
||||||
prefix: '~',
|
|
||||||
meta,
|
|
||||||
helpers,
|
|
||||||
innerRef,
|
|
||||||
setSelectionRange,
|
|
||||||
SuggestComponent: TerritorySuggest
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleTextChange = useCallback((e) => {
|
|
||||||
// Try to match user mentions first, then territories
|
|
||||||
if (!userAutocomplete.handleTextChange(e)) {
|
|
||||||
territoryAutocomplete.handleTextChange(e)
|
|
||||||
}
|
|
||||||
}, [userAutocomplete, territoryAutocomplete])
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e, userOnKeyDown, territoryOnKeyDown) => {
|
|
||||||
const metaOrCtrl = e.metaKey || e.ctrlKey
|
|
||||||
if (!metaOrCtrl) {
|
|
||||||
if (userAutocomplete.entityData) {
|
|
||||||
return userOnKeyDown(e)
|
|
||||||
} else if (territoryAutocomplete.entityData) {
|
|
||||||
return territoryOnKeyDown(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false // Didn't handle the event
|
|
||||||
}, [userAutocomplete.entityData, territoryAutocomplete.entityData])
|
|
||||||
|
|
||||||
const handleBlur = useCallback((resetUserSuggestions, resetTerritorySuggestions) => {
|
|
||||||
setTimeout(resetUserSuggestions, 500)
|
|
||||||
setTimeout(resetTerritorySuggestions, 500)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
userAutocomplete,
|
|
||||||
territoryAutocomplete,
|
|
||||||
handleTextChange,
|
|
||||||
handleKeyDown,
|
|
||||||
handleBlur
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DualAutocompleteWrapper ({
|
|
||||||
userAutocomplete,
|
|
||||||
territoryAutocomplete,
|
|
||||||
children
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<UserSuggest
|
|
||||||
query={userAutocomplete.entityData?.query}
|
|
||||||
onSelect={userAutocomplete.handleSelect}
|
|
||||||
dropdownStyle={userAutocomplete.entityData?.style}
|
|
||||||
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions: resetUserSuggestions }) => (
|
|
||||||
<TerritorySuggest
|
|
||||||
query={territoryAutocomplete.entityData?.query}
|
|
||||||
onSelect={territoryAutocomplete.handleSelect}
|
|
||||||
dropdownStyle={territoryAutocomplete.entityData?.style}
|
|
||||||
>{({ onKeyDown: territorySuggestOnKeyDown, resetSuggestions: resetTerritorySuggestions }) =>
|
|
||||||
children({
|
|
||||||
userSuggestOnKeyDown,
|
|
||||||
territorySuggestOnKeyDown,
|
|
||||||
resetUserSuggestions,
|
|
||||||
resetTerritorySuggestions
|
|
||||||
})}
|
|
||||||
</TerritorySuggest>
|
|
||||||
)}
|
|
||||||
</UserSuggest>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
|
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
|
||||||
const [tab, setTab] = useState('write')
|
const [tab, setTab] = useState('write')
|
||||||
const [, meta, helpers] = useField(props)
|
const [, meta, helpers] = useField(props)
|
||||||
@ -317,8 +151,10 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
const [updateUploadFees] = useLazyQuery(gql`
|
const [updateUploadFees] = useLazyQuery(gql`
|
||||||
query uploadFees($s3Keys: [Int]!) {
|
query uploadFees($s3Keys: [Int]!) {
|
||||||
uploadFees(s3Keys: $s3Keys) {
|
uploadFees(s3Keys: $s3Keys) {
|
||||||
|
totalFees
|
||||||
nUnpaid
|
nUnpaid
|
||||||
uploadFees
|
uploadFees
|
||||||
|
bytes24h
|
||||||
}
|
}
|
||||||
}`, {
|
}`, {
|
||||||
fetchPolicy: 'no-cache',
|
fetchPolicy: 'no-cache',
|
||||||
@ -327,15 +163,13 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
},
|
},
|
||||||
onCompleted: ({ uploadFees }) => {
|
onCompleted: ({ uploadFees }) => {
|
||||||
const { uploadFees: feePerUpload, nUnpaid } = uploadFees
|
|
||||||
const totalFees = feePerUpload * nUnpaid
|
|
||||||
merge({
|
merge({
|
||||||
uploadFees: {
|
uploadFees: {
|
||||||
term: `+ ${numWithUnits(feePerUpload, { abbreviate: false })} x ${nUnpaid}`,
|
term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`,
|
||||||
label: 'upload fee',
|
label: 'upload fee',
|
||||||
op: '+',
|
op: '+',
|
||||||
modifier: cost => cost + totalFees,
|
modifier: cost => cost + uploadFees.totalFees,
|
||||||
omit: !totalFees
|
omit: !uploadFees.totalFees
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -364,12 +198,18 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
}
|
}
|
||||||
}, [innerRef, selectionRange.start, selectionRange.end])
|
}, [innerRef, selectionRange.start, selectionRange.end])
|
||||||
|
|
||||||
const { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({
|
const [mention, setMention] = useState()
|
||||||
meta,
|
const insertMention = useCallback((name) => {
|
||||||
helpers,
|
if (mention?.start === undefined || mention?.end === undefined) return
|
||||||
innerRef,
|
const { start, end } = mention
|
||||||
setSelectionRange
|
setMention(undefined)
|
||||||
})
|
const first = `${meta?.value.substring(0, start)}@${name}`
|
||||||
|
const second = meta?.value.substring(end)
|
||||||
|
const updatedValue = `${first}${second}`
|
||||||
|
helpers.setValue(updatedValue)
|
||||||
|
setSelectionRange({ start: first.length, end: first.length })
|
||||||
|
innerRef.current.focus()
|
||||||
|
}, [mention, meta?.value, helpers?.setValue])
|
||||||
|
|
||||||
const uploadFeesUpdate = useDebounceCallback(
|
const uploadFeesUpdate = useDebounceCallback(
|
||||||
(text) => {
|
(text) => {
|
||||||
@ -379,9 +219,86 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
|
|
||||||
const onChangeInner = useCallback((formik, e) => {
|
const onChangeInner = useCallback((formik, e) => {
|
||||||
if (onChange) onChange(formik, e)
|
if (onChange) onChange(formik, e)
|
||||||
uploadFeesUpdate(e.target.value)
|
// check for mention editing
|
||||||
handleTextChange(e)
|
const { value, selectionStart } = e.target
|
||||||
}, [onChange, uploadFeesUpdate, handleTextChange])
|
uploadFeesUpdate(value)
|
||||||
|
|
||||||
|
if (!value || selectionStart === undefined) {
|
||||||
|
setMention(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let priorSpace = -1
|
||||||
|
for (let i = selectionStart - 1; i >= 0; i--) {
|
||||||
|
if (/[^\w@]/.test(value[i])) {
|
||||||
|
priorSpace = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let nextSpace = value.length
|
||||||
|
for (let i = selectionStart; i <= value.length; i++) {
|
||||||
|
if (/[^\w]/.test(value[i])) {
|
||||||
|
nextSpace = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const currentSegment = value.substring(priorSpace + 1, nextSpace)
|
||||||
|
|
||||||
|
// set the query to the current character segment and note where it appears
|
||||||
|
if (/^@\w*$/.test(currentSegment)) {
|
||||||
|
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
|
||||||
|
setMention({
|
||||||
|
query: currentSegment,
|
||||||
|
start: priorSpace + 1,
|
||||||
|
end: nextSpace,
|
||||||
|
style: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
|
||||||
|
left: `${left}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setMention(undefined)
|
||||||
|
}
|
||||||
|
}, [onChange, setMention, uploadFeesUpdate])
|
||||||
|
|
||||||
|
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
|
||||||
|
return (e) => {
|
||||||
|
const metaOrCtrl = e.metaKey || e.ctrlKey
|
||||||
|
if (metaOrCtrl) {
|
||||||
|
if (e.key === 'k') {
|
||||||
|
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
|
||||||
|
e.preventDefault()
|
||||||
|
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||||
|
}
|
||||||
|
if (e.key === 'b') {
|
||||||
|
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
|
||||||
|
e.preventDefault()
|
||||||
|
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||||
|
}
|
||||||
|
if (e.key === 'i') {
|
||||||
|
// some browsers might use CTRL+I to do something else so prevent that behavior too
|
||||||
|
e.preventDefault()
|
||||||
|
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||||
|
}
|
||||||
|
if (e.key === 'u') {
|
||||||
|
// some browsers might use CTRL+U to do something else so prevent that behavior too
|
||||||
|
e.preventDefault()
|
||||||
|
imageUploadRef.current?.click()
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab' && e.altKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metaOrCtrl) {
|
||||||
|
userSuggestOnKeyDown(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onKeyDown) onKeyDown(e)
|
||||||
|
}
|
||||||
|
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown])
|
||||||
|
|
||||||
const onPaste = useCallback((event) => {
|
const onPaste = useCallback((event) => {
|
||||||
const items = event.clipboardData.items
|
const items = event.clipboardData.items
|
||||||
@ -425,44 +342,6 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
setDragStyle(null)
|
setDragStyle(null)
|
||||||
}, [setDragStyle])
|
}, [setDragStyle])
|
||||||
|
|
||||||
const onKeyDownInner = useCallback((userSuggestOnKeyDown, territorySuggestOnKeyDown) => {
|
|
||||||
return (e) => {
|
|
||||||
const metaOrCtrl = e.metaKey || e.ctrlKey
|
|
||||||
|
|
||||||
// Handle markdown shortcuts first
|
|
||||||
if (metaOrCtrl) {
|
|
||||||
if (e.key === 'k') {
|
|
||||||
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
|
|
||||||
e.preventDefault()
|
|
||||||
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
||||||
}
|
|
||||||
if (e.key === 'b') {
|
|
||||||
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
|
|
||||||
e.preventDefault()
|
|
||||||
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
||||||
}
|
|
||||||
if (e.key === 'i') {
|
|
||||||
// some browsers might use CTRL+I to do something else so prevent that behavior too
|
|
||||||
e.preventDefault()
|
|
||||||
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
||||||
}
|
|
||||||
if (e.key === 'u') {
|
|
||||||
// some browsers might use CTRL+U to do something else so prevent that behavior too
|
|
||||||
e.preventDefault()
|
|
||||||
imageUploadRef.current?.click()
|
|
||||||
}
|
|
||||||
if (e.key === 'Tab' && e.altKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handleKeyDown(e, userSuggestOnKeyDown, territorySuggestOnKeyDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onKeyDown) onKeyDown(e)
|
|
||||||
}
|
|
||||||
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown, handleKeyDown, imageUploadRef])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup label={label} className={groupClassName}>
|
<FormGroup label={label} className={groupClassName}>
|
||||||
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
|
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
|
||||||
@ -529,25 +408,24 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
</span>
|
</span>
|
||||||
</Nav>
|
</Nav>
|
||||||
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
|
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
|
||||||
<DualAutocompleteWrapper
|
<UserSuggest
|
||||||
userAutocomplete={userAutocomplete}
|
query={mention?.query}
|
||||||
territoryAutocomplete={territoryAutocomplete}
|
onSelect={insertMention}
|
||||||
>
|
dropdownStyle={mention?.style}
|
||||||
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => (
|
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions }) => (
|
||||||
<InputInner
|
<InputInner
|
||||||
innerRef={innerRef}
|
innerRef={innerRef}
|
||||||
{...props}
|
{...props}
|
||||||
onChange={onChangeInner}
|
onChange={onChangeInner}
|
||||||
onKeyDown={onKeyDownInner(userSuggestOnKeyDown, territorySuggestOnKeyDown)}
|
onKeyDown={onKeyDownInner(userSuggestOnKeyDown)}
|
||||||
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)}
|
onBlur={() => setTimeout(resetSuggestions, 500)}
|
||||||
onDragEnter={onDragEnter}
|
onDragEnter={onDragEnter}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
className={dragStyle === 'over' ? styles.dragOver : ''}
|
className={dragStyle === 'over' ? styles.dragOver : ''}
|
||||||
/>
|
/>)}
|
||||||
)}
|
</UserSuggest>
|
||||||
</DualAutocompleteWrapper>
|
|
||||||
</div>
|
</div>
|
||||||
{tab !== 'write' &&
|
{tab !== 'write' &&
|
||||||
<div className='form-group'>
|
<div className='form-group'>
|
||||||
@ -609,7 +487,7 @@ function FormGroup ({ className, label, children }) {
|
|||||||
function InputInner ({
|
function InputInner ({
|
||||||
prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue,
|
prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue,
|
||||||
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, hideError,
|
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, hideError,
|
||||||
AppendColumn, ...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
|
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
|
||||||
const formik = noForm ? null : useFormikContext()
|
const formik = noForm ? null : useFormikContext()
|
||||||
@ -687,8 +565,6 @@ function InputInner ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Row>
|
|
||||||
<Col>
|
|
||||||
<InputGroup hasValidation className={inputGroupClassName}>
|
<InputGroup hasValidation className={inputGroupClassName}>
|
||||||
{prepend}
|
{prepend}
|
||||||
<BootstrapForm.Control
|
<BootstrapForm.Control
|
||||||
@ -721,9 +597,6 @@ function InputInner ({
|
|||||||
{meta.touched && meta.error}
|
{meta.touched && meta.error}
|
||||||
</BootstrapForm.Control.Feedback>
|
</BootstrapForm.Control.Feedback>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</Col>
|
|
||||||
{AppendColumn && <AppendColumn className={meta.touched && meta.error ? 'invisible' : ''} />}
|
|
||||||
</Row>
|
|
||||||
{hint && (
|
{hint && (
|
||||||
<BootstrapForm.Text>
|
<BootstrapForm.Text>
|
||||||
{hint}
|
{hint}
|
||||||
@ -744,34 +617,34 @@ function InputInner ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
|
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
|
||||||
|
export function UserSuggest ({
|
||||||
export function BaseSuggest ({
|
query, onSelect, dropdownStyle, children,
|
||||||
query, onSelect, dropdownStyle,
|
transformUser = user => user, selectWithTab = true, filterUsers = () => true
|
||||||
transformItem = item => item, selectWithTab = true, filterItems = () => true,
|
|
||||||
getSuggestionsQuery, queryName, itemsField,
|
|
||||||
children
|
|
||||||
}) {
|
}) {
|
||||||
const [getSuggestions] = useLazyQuery(getSuggestionsQuery, {
|
const [getSuggestions] = useLazyQuery(USER_SUGGESTIONS, {
|
||||||
onCompleted: data => {
|
onCompleted: data => {
|
||||||
query !== undefined && setSuggestions({
|
query !== undefined && setSuggestions({
|
||||||
array: data[itemsField]
|
array: data.userSuggestions
|
||||||
.filter((...args) => filterItems(query, ...args))
|
.filter((...args) => filterUsers(query, ...args))
|
||||||
.map(transformItem),
|
.map(transformUser),
|
||||||
index: 0
|
index: 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
|
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
|
||||||
const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), [])
|
const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query !== undefined) {
|
if (query !== undefined) {
|
||||||
// remove the leading character and any trailing spaces
|
// remove both the leading @ and any @domain after nym
|
||||||
const q = query?.replace(/^[@ ~]+|[ ]+$/g, '').replace(/@[^\s]*$/, '').replace(/~[^\s]*$/, '')
|
const q = query?.replace(/^[@ ]+|[ ]+$/g, '').replace(/@[^\s]*$/, '')
|
||||||
getSuggestions({ variables: { q, limit: 5 } })
|
getSuggestions({ variables: { q, limit: 5 } })
|
||||||
} else {
|
} else {
|
||||||
resetSuggestions()
|
resetSuggestions()
|
||||||
}
|
}
|
||||||
}, [query, resetSuggestions, getSuggestions])
|
}, [query, resetSuggestions, getSuggestions])
|
||||||
|
|
||||||
const onKeyDown = useCallback(e => {
|
const onKeyDown = useCallback(e => {
|
||||||
switch (e.code) {
|
switch (e.code) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
@ -816,6 +689,7 @@ export function BaseSuggest ({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}, [onSelect, resetSuggestions, suggestions])
|
}, [onSelect, resetSuggestions, suggestions])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children?.({ onKeyDown, resetSuggestions })}
|
{children?.({ onKeyDown, resetSuggestions })}
|
||||||
@ -838,17 +712,17 @@ export function BaseSuggest ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BaseInputSuggest ({
|
export function InputUserSuggest ({
|
||||||
label, groupClassName, transformItem, filterItems,
|
label, groupClassName, transformUser, filterUsers,
|
||||||
selectWithTab, onChange, transformQuery, SuggestComponent, prefixRegex, ...props
|
selectWithTab, onChange, transformQuery, ...props
|
||||||
}) {
|
}) {
|
||||||
const [ovalue, setOValue] = useState()
|
const [ovalue, setOValue] = useState()
|
||||||
const [query, setQuery] = useState()
|
const [query, setQuery] = useState()
|
||||||
return (
|
return (
|
||||||
<FormGroup label={label} className={groupClassName}>
|
<FormGroup label={label} className={groupClassName}>
|
||||||
<SuggestComponent
|
<UserSuggest
|
||||||
transformItem={transformItem}
|
transformUser={transformUser}
|
||||||
filterItems={filterItems}
|
filterUsers={filterUsers}
|
||||||
selectWithTab={selectWithTab}
|
selectWithTab={selectWithTab}
|
||||||
onSelect={(v) => {
|
onSelect={(v) => {
|
||||||
// HACK ... ovalue does not trigger onChange
|
// HACK ... ovalue does not trigger onChange
|
||||||
@ -863,85 +737,19 @@ function BaseInputSuggest ({
|
|||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
onChange={(formik, e) => {
|
onChange={(formik, e) => {
|
||||||
onChange && onChange(formik, e)
|
onChange && onChange(formik, e)
|
||||||
if (e.target.value === ovalue) {
|
|
||||||
// we don't need to set the ovalue or query if the value is the same
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setOValue(e.target.value)
|
setOValue(e.target.value)
|
||||||
setQuery(e.target.value.replace(prefixRegex, ''))
|
setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, ''))
|
||||||
}}
|
}}
|
||||||
overrideValue={ovalue}
|
overrideValue={ovalue}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onBlur={() => setTimeout(resetSuggestions, 500)}
|
onBlur={() => setTimeout(resetSuggestions, 500)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SuggestComponent>
|
</UserSuggest>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InputUserSuggest ({
|
|
||||||
transformUser, filterUsers, ...props
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<BaseInputSuggest
|
|
||||||
transformItem={transformUser}
|
|
||||||
filterItems={filterUsers}
|
|
||||||
SuggestComponent={UserSuggest}
|
|
||||||
prefixRegex={/^[@ ]+|[ ]+$/g}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InputTerritorySuggest ({
|
|
||||||
transformSub, filterSubs, ...props
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<BaseInputSuggest
|
|
||||||
transformItem={transformSub}
|
|
||||||
filterItems={filterSubs}
|
|
||||||
SuggestComponent={TerritorySuggest}
|
|
||||||
prefixRegex={/^[~ ]+|[ ]+$/g}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserSuggest ({
|
|
||||||
transformUser = user => user, filterUsers = () => true,
|
|
||||||
children, ...props
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<BaseSuggest
|
|
||||||
transformItem={transformUser}
|
|
||||||
filterItems={filterUsers}
|
|
||||||
getSuggestionsQuery={USER_SUGGESTIONS}
|
|
||||||
itemsField='userSuggestions'
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</BaseSuggest>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TerritorySuggest ({
|
|
||||||
transformSub = sub => sub, filterSubs = () => true,
|
|
||||||
children, ...props
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<BaseSuggest
|
|
||||||
transformItem={transformSub}
|
|
||||||
filterItems={filterSubs}
|
|
||||||
getSuggestionsQuery={SUB_SUGGESTIONS}
|
|
||||||
itemsField='subSuggestions'
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</BaseSuggest>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Input ({ label, groupClassName, under, ...props }) {
|
export function Input ({ label, groupClassName, under, ...props }) {
|
||||||
return (
|
return (
|
||||||
<FormGroup label={label} className={groupClassName}>
|
<FormGroup label={label} className={groupClassName}>
|
||||||
@ -957,28 +765,22 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
|
|||||||
<FieldArray name={name} hasValidation>
|
<FieldArray name={name} hasValidation>
|
||||||
{({ form, ...fieldArrayHelpers }) => {
|
{({ form, ...fieldArrayHelpers }) => {
|
||||||
const options = form.values[name]
|
const options = form.values[name]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{options?.map((_, i) => {
|
{options?.map((_, i) => (
|
||||||
const AppendColumn = ({ className }) => (
|
|
||||||
<Col className={`d-flex ps-0 ${className}`} xs='auto'>
|
|
||||||
{options.length - 1 === i && options.length !== max
|
|
||||||
// onMouseDown is used to prevent the blur event on text inputs from overriding the click event
|
|
||||||
? <AddIcon className='fill-grey align-self-center justify-self-center pointer' onMouseDown={() => fieldArrayHelpers.push(emptyItem)} />
|
|
||||||
// filler div for col alignment across rows
|
|
||||||
: <div style={{ width: '24px', height: '24px' }} />}
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
<Row className='mb-2'>
|
<Row className='mb-2'>
|
||||||
<Col>
|
<Col>
|
||||||
{children
|
{children
|
||||||
? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined, AppendColumn })
|
? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined })
|
||||||
: <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} AppendColumn={AppendColumn} />}
|
: <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} />}
|
||||||
|
</Col>
|
||||||
|
<Col className='d-flex ps-0' xs='auto'>
|
||||||
|
{options.length - 1 === i && options.length !== max
|
||||||
|
? <AddIcon className='fill-grey align-self-center justify-self-center pointer' onClick={() => fieldArrayHelpers.push(emptyItem)} />
|
||||||
|
// filler div for col alignment across rows
|
||||||
|
: <div style={{ width: '24px', height: '24px' }} />}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{options.length - 1 === i &&
|
{options.length - 1 === i &&
|
||||||
<>
|
<>
|
||||||
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
|
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
|
||||||
@ -987,8 +789,7 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
|
|||||||
</>}
|
</>}
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@ -1077,14 +878,15 @@ export function Form ({
|
|||||||
})
|
})
|
||||||
}, [storageKeyPrefix])
|
}, [storageKeyPrefix])
|
||||||
|
|
||||||
const onSubmitInner = useCallback(async (values, ...args) => {
|
const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => {
|
||||||
|
const variables = { amount, ...values }
|
||||||
if (requireSession && !me) {
|
if (requireSession && !me) {
|
||||||
throw new SessionRequiredError()
|
throw new SessionRequiredError()
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (onSubmit) {
|
if (onSubmit) {
|
||||||
await onSubmit(values, ...args)
|
await onSubmit(variables, ...args)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err.message, err)
|
console.log(err.message, err)
|
||||||
@ -1342,6 +1144,33 @@ function PasswordHider ({ onClick, showPass }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QrPassword ({ value }) {
|
||||||
|
const showModal = useShowModal()
|
||||||
|
const toaster = useToast()
|
||||||
|
|
||||||
|
const showQr = useCallback(() => {
|
||||||
|
showModal(close => (
|
||||||
|
<div>
|
||||||
|
<p className='line-height-md text-muted'>Import this passphrase into another device by navigating to device sync settings and scanning this QR code</p>
|
||||||
|
<div className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }}>
|
||||||
|
<QRCodeSVG className='h-auto mw-100' value={value} size={300} imageSettings={qrImageSettings} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}, [toaster, value, showModal])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InputGroup.Text
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={showQr}
|
||||||
|
>
|
||||||
|
<QrIcon height={16} width={16} />
|
||||||
|
</InputGroup.Text>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function PasswordScanner ({ onScan, text }) {
|
function PasswordScanner ({ onScan, text }) {
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
@ -1362,10 +1191,8 @@ function PasswordScanner ({ onScan, text }) {
|
|||||||
<Scanner
|
<Scanner
|
||||||
formats={['qr_code']}
|
formats={['qr_code']}
|
||||||
onScan={([{ rawValue: result }]) => {
|
onScan={([{ rawValue: result }]) => {
|
||||||
if (result) {
|
|
||||||
onScan(result)
|
onScan(result)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
styles={{
|
styles={{
|
||||||
video: {
|
video: {
|
||||||
@ -1380,7 +1207,6 @@ function PasswordScanner ({ onScan, text }) {
|
|||||||
}
|
}
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
components={{ audio: false }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1407,12 +1233,12 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, value: ini
|
|||||||
{copy && (
|
{copy && (
|
||||||
<CopyButton icon value={field?.value} />
|
<CopyButton icon value={field?.value} />
|
||||||
)}
|
)}
|
||||||
{qr && (
|
{qr && (readOnly
|
||||||
<PasswordScanner
|
? <QrPassword value={field?.value} />
|
||||||
|
: <PasswordScanner
|
||||||
text="Where'd you learn to square dance?"
|
text="Where'd you learn to square dance?"
|
||||||
onScan={v => helpers.setValue(v)}
|
onScan={v => helpers.setValue(v)}
|
||||||
/>
|
/>)}
|
||||||
)}
|
|
||||||
{append}
|
{append}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -13,12 +13,12 @@ export default function CCInfo (props) {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>if the zap is small and you don't have a direct channel to SN, the routing fee may exceed SN's 3% max fee</li>
|
<li>if the zap is small and you don't have a direct channel to SN, the routing fee may exceed SN's 3% max fee</li>
|
||||||
<li>check your <Link href='/wallets/logs'>wallet logs</Link> for clues</li>
|
<li>check your <Link href='/wallets/logs'>wallet logs</Link> for clues</li>
|
||||||
<li>if you have questions about the errors in your wallet logs, mention the error in the <Link href='/api/daily'>saloon</Link></li>
|
<li>if you have questions about the errors in your wallet logs, mention the error in the <Link href='/daily'>saloon</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>some zaps might be smaller than your configured receiving dust limit
|
<li>some zaps might be smaller than your configured receiving dust limit
|
||||||
<ul>
|
<ul>
|
||||||
<li>you can configure your dust limit in your <Link href='/wallets/settings'>wallet settings</Link></li>
|
<li>you can configure your dust limit in your <Link href='/settings'>settings</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -6,9 +6,9 @@ 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/invoice'
|
import { INVOICE } from '@/fragments/wallet'
|
||||||
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||||
import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/client/errors'
|
import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/errors'
|
||||||
import ItemJob from './item-job'
|
import ItemJob from './item-job'
|
||||||
import Item from './item'
|
import Item from './item'
|
||||||
import { CommentFlat } from './comment'
|
import { CommentFlat } from './comment'
|
||||||
|
|||||||
@ -6,22 +6,19 @@ import { useMe } from './me'
|
|||||||
import UpBolt from '@/svgs/bolt.svg'
|
import UpBolt from '@/svgs/bolt.svg'
|
||||||
import { amountSchema, boostSchema } from '@/lib/validate'
|
import { amountSchema, boostSchema } from '@/lib/validate'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
|
import { useLightning } from './lightning'
|
||||||
import { nextTip, defaultTipIncludingRandom } from './upvote'
|
import { nextTip, defaultTipIncludingRandom } from './upvote'
|
||||||
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
|
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
|
||||||
import { usePaidMutation } from './use-paid-mutation'
|
import { usePaidMutation } from './use-paid-mutation'
|
||||||
import { ACT_MUTATION } from '@/fragments/paidAction'
|
import { ACT_MUTATION } from '@/fragments/paidAction'
|
||||||
import { meAnonSats } from '@/lib/apollo'
|
import { meAnonSats } from '@/lib/apollo'
|
||||||
import { BoostItemInput } from './adv-post-form'
|
import { BoostItemInput } from './adv-post-form'
|
||||||
import { useHasSendWallet } from '@/wallets/client/hooks'
|
import { useSendWallets } from '@/wallets/index'
|
||||||
import { useAnimation } from '@/components/animation'
|
|
||||||
|
|
||||||
const defaultTips = [100, 1000, 10_000, 100_000]
|
const defaultTips = [100, 1000, 10_000, 100_000]
|
||||||
|
|
||||||
const Tips = ({ setOValue }) => {
|
const Tips = ({ setOValue }) => {
|
||||||
const customTips = getCustomTips()
|
const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
|
||||||
const defaultNoCustom = defaultTips.filter(d => !customTips.includes(d))
|
|
||||||
const tips = [...customTips, ...defaultNoCustom].slice(0, 7).sort((a, b) => a - b)
|
|
||||||
|
|
||||||
return tips.map((num, i) =>
|
return tips.map((num, i) =>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
@ -40,7 +37,11 @@ const Tips = ({ setOValue }) => {
|
|||||||
const getCustomTips = () => JSON.parse(window.localStorage.getItem('custom-tips')) || []
|
const getCustomTips = () => JSON.parse(window.localStorage.getItem('custom-tips')) || []
|
||||||
|
|
||||||
const addCustomTip = (amount) => {
|
const addCustomTip = (amount) => {
|
||||||
const customTips = Array.from(new Set([amount, ...getCustomTips()])).slice(0, 7)
|
if (defaultTips.includes(amount)) return
|
||||||
|
let customTips = Array.from(new Set([amount, ...getCustomTips()]))
|
||||||
|
if (customTips.length > 3) {
|
||||||
|
customTips = customTips.slice(0, 3)
|
||||||
|
}
|
||||||
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
|
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,7 +89,7 @@ function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'B
|
|||||||
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
|
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
const hasSendWallet = useHasSendWallet()
|
const wallets = useSendWallets()
|
||||||
const [oValue, setOValue] = useState()
|
const [oValue, setOValue] = useState()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -96,7 +97,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
|||||||
}, [onClose, item.id])
|
}, [onClose, item.id])
|
||||||
|
|
||||||
const actor = useAct()
|
const actor = useAct()
|
||||||
const animate = useAnimation()
|
const strike = useLightning()
|
||||||
|
|
||||||
const onSubmit = useCallback(async ({ amount }) => {
|
const onSubmit = useCallback(async ({ amount }) => {
|
||||||
if (abortSignal && zapUndoTrigger({ me, amount })) {
|
if (abortSignal && zapUndoTrigger({ me, amount })) {
|
||||||
@ -111,12 +112,12 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onPaid = () => {
|
const onPaid = () => {
|
||||||
animate()
|
strike()
|
||||||
onClose?.()
|
onClose?.()
|
||||||
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeImmediately = hasSendWallet || me?.privates?.sats > Number(amount)
|
const closeImmediately = wallets.length > 0 || me?.privates?.sats > Number(amount)
|
||||||
if (closeImmediately) {
|
if (closeImmediately) {
|
||||||
onPaid()
|
onPaid()
|
||||||
}
|
}
|
||||||
@ -126,7 +127,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
sats: Number(amount),
|
sats: Number(amount),
|
||||||
act,
|
act,
|
||||||
hasSendWallet
|
hasSendWallet: wallets.length > 0
|
||||||
},
|
},
|
||||||
optimisticResponse: me
|
optimisticResponse: me
|
||||||
? {
|
? {
|
||||||
@ -143,7 +144,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
|||||||
})
|
})
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
addCustomTip(Number(amount))
|
addCustomTip(Number(amount))
|
||||||
}, [me, actor, hasSendWallet, act, item.id, onClose, abortSignal, animate])
|
}, [me, actor, wallets.length, act, item.id, onClose, abortSignal, strike])
|
||||||
|
|
||||||
return act === 'BOOST'
|
return act === 'BOOST'
|
||||||
? <BoostForm step={step} onSubmit={onSubmit} item={item} inputRef={inputRef} act={act}>{children}</BoostForm>
|
? <BoostForm step={step} onSubmit={onSubmit} item={item} inputRef={inputRef} act={act}>{children}</BoostForm>
|
||||||
@ -263,13 +264,13 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
|
|||||||
// because the mutation name we use varies,
|
// because the mutation name we use varies,
|
||||||
// we need to extract the result/invoice from the response
|
// we need to extract the result/invoice from the response
|
||||||
const getPaidActionResult = data => Object.values(data)[0]
|
const getPaidActionResult = data => Object.values(data)[0]
|
||||||
const hasSendWallet = useHasSendWallet()
|
const wallets = useSendWallets()
|
||||||
|
|
||||||
const [act] = usePaidMutation(query, {
|
const [act] = usePaidMutation(query, {
|
||||||
waitFor: inv =>
|
waitFor: inv =>
|
||||||
// if we have attached wallets, we might be paying a wrapped invoice in which case we need to make sure
|
// if we have attached wallets, we might be paying a wrapped invoice in which case we need to make sure
|
||||||
// we don't prematurely consider the payment as successful (important for receiver fallbacks)
|
// we don't prematurely consider the payment as successful (important for receiver fallbacks)
|
||||||
hasSendWallet
|
wallets.length > 0
|
||||||
? inv?.actionState === 'PAID'
|
? inv?.actionState === 'PAID'
|
||||||
: inv?.satsReceived > 0,
|
: inv?.satsReceived > 0,
|
||||||
...options,
|
...options,
|
||||||
@ -298,9 +299,9 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useZap () {
|
export function useZap () {
|
||||||
const hasSendWallet = useHasSendWallet()
|
const wallets = useSendWallets()
|
||||||
const act = useAct()
|
const act = useAct()
|
||||||
const animate = useAnimation()
|
const strike = useLightning()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
|
|
||||||
return useCallback(async ({ item, me, abortSignal }) => {
|
return useCallback(async ({ item, me, abortSignal }) => {
|
||||||
@ -309,14 +310,14 @@ export function useZap () {
|
|||||||
// add current sats to next tip since idempotent zaps use desired total zap not difference
|
// add current sats to next tip since idempotent zaps use desired total zap not difference
|
||||||
const sats = nextTip(meSats, { ...me?.privates })
|
const sats = nextTip(meSats, { ...me?.privates })
|
||||||
|
|
||||||
const variables = { id: item.id, sats, act: 'TIP', hasSendWallet }
|
const variables = { id: item.id, sats, act: 'TIP', hasSendWallet: wallets.length > 0 }
|
||||||
const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } }
|
const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await abortSignal.pause({ me, amount: sats })
|
await abortSignal.pause({ me, amount: sats })
|
||||||
animate()
|
strike()
|
||||||
// batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request
|
// batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request
|
||||||
const { error } = await act({ variables, optimisticResponse, context: { batch: hasSendWallet || me?.privates?.sats > sats } })
|
const { error } = await act({ variables, optimisticResponse, context: { batch: wallets.length > 0 || me?.privates?.sats > sats } })
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ActCanceledError) {
|
if (error instanceof ActCanceledError) {
|
||||||
@ -327,7 +328,7 @@ export function useZap () {
|
|||||||
// but right now this toast is noisy for optimistic zaps
|
// but right now this toast is noisy for optimistic zaps
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}, [act, toaster, animate, hasSendWallet])
|
}, [act, toaster, strike, wallets.length])
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ActCanceledError extends Error {
|
export class ActCanceledError extends Error {
|
||||||
|
|||||||
@ -191,8 +191,6 @@ export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props
|
|||||||
comments={item.comments.comments}
|
comments={item.comments.comments}
|
||||||
commentsCursor={item.comments.cursor}
|
commentsCursor={item.comments.cursor}
|
||||||
fetchMoreComments={fetchMoreComments}
|
fetchMoreComments={fetchMoreComments}
|
||||||
lastCommentAt={item.lastCommentAt}
|
|
||||||
item={item}
|
|
||||||
/>
|
/>
|
||||||
</div>}
|
</div>}
|
||||||
</CarouselProvider>
|
</CarouselProvider>
|
||||||
|
|||||||
@ -89,14 +89,11 @@ export default function ItemInfo ({
|
|||||||
const myPost = (me && root && Number(me.id) === Number(root.user.id))
|
const myPost = (me && root && Number(me.id) === Number(root.user.id))
|
||||||
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 isPinnedPost = isPost && item.position && (pinnable || !item.subName)
|
|
||||||
const isPinnedSubReply = !isPost && item.position && !item.subName
|
|
||||||
const isAd = !item.parentId && Number(item.user?.id) === USER_ID.ad
|
|
||||||
const meSats = (me ? item.meSats : item.meAnonSats) || 0
|
const meSats = (me ? item.meSats : item.meAnonSats) || 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className || `${styles.other}`}>
|
<div className={className || `${styles.other}`}>
|
||||||
{!isPinnedPost && !(isPinnedSubReply && !full) && !isAd &&
|
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
|
||||||
<>
|
<>
|
||||||
<span title={itemTitle(item)}>
|
<span title={itemTitle(item)}>
|
||||||
{numWithUnits(item.sats)}
|
{numWithUnits(item.sats)}
|
||||||
@ -110,7 +107,7 @@ export default function ItemInfo ({
|
|||||||
</>}
|
</>}
|
||||||
<Link
|
<Link
|
||||||
href={`/items/${item.id}`} onClick={(e) => {
|
href={`/items/${item.id}`} onClick={(e) => {
|
||||||
const viewedAt = commentsViewedAt(item.id)
|
const viewedAt = commentsViewedAt(item)
|
||||||
if (viewedAt) {
|
if (viewedAt) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
router.push(
|
router.push(
|
||||||
@ -285,7 +282,7 @@ function InfoDropdownItem ({ item }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentInfo ({ item, disableRetry, setDisableRetry }) {
|
function PaymentInfo ({ item, disableRetry, setDisableRetry }) {
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const retryCreateItem = useRetryCreateItem({ id: item.id })
|
const retryCreateItem = useRetryCreateItem({ id: item.id })
|
||||||
|
|||||||
@ -13,9 +13,8 @@ import { MEDIA_URL } from '@/lib/constants'
|
|||||||
import { abbrNum } from '@/lib/format'
|
import { abbrNum } from '@/lib/format'
|
||||||
import { Badge } from 'react-bootstrap'
|
import { Badge } from 'react-bootstrap'
|
||||||
import SubPopover from './sub-popover'
|
import SubPopover from './sub-popover'
|
||||||
import { PaymentInfo } from './item-info'
|
|
||||||
|
|
||||||
export default function ItemJob ({ item, toc, rank, children, disableRetry, setDisableRetry }) {
|
export default function ItemJob ({ item, toc, rank, children }) {
|
||||||
const isEmail = string().email().isValidSync(item.url)
|
const isEmail = string().email().isValidSync(item.url)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -79,7 +78,6 @@ export default function ItemJob ({ item, toc, rank, children, disableRetry, setD
|
|||||||
<Link href={`/items/${item.id}/edit`} className='text-reset fw-bold'>
|
<Link href={`/items/${item.id}/edit`} className='text-reset fw-bold'>
|
||||||
edit
|
edit
|
||||||
</Link>
|
</Link>
|
||||||
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
|
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -29,7 +29,7 @@ import { useShowModal } from './modal'
|
|||||||
import { BoostHelp } from './adv-post-form'
|
import { BoostHelp } from './adv-post-form'
|
||||||
|
|
||||||
function onItemClick (e, router, item) {
|
function onItemClick (e, router, item) {
|
||||||
const viewedAt = commentsViewedAt(item.id)
|
const viewedAt = commentsViewedAt(item)
|
||||||
if (viewedAt) {
|
if (viewedAt) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
|||||||
@ -45,18 +45,18 @@ function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LightningExplainer ({ text, children, backButton, md = 12, lg = 6 }) {
|
function LightningExplainer ({ text, children }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<div className={styles.login}>
|
<div className={styles.login}>
|
||||||
{backButton && <div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>}
|
<div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>
|
||||||
<h3 className='w-100 pb-2'>
|
<h3 className='w-100 pb-2'>
|
||||||
{text || 'Login'} with Lightning
|
{text || 'Login'} with Lightning
|
||||||
</h3>
|
</h3>
|
||||||
<div className='fw-bold text-muted pb-4'>This is the most private way to use Stacker News. Just open your Lightning wallet and scan the QR code.</div>
|
<div className='fw-bold text-muted pb-4'>This is the most private way to use Stacker News. Just open your Lightning wallet and scan the QR code.</div>
|
||||||
<Row className='w-100 text-muted'>
|
<Row className='w-100 text-muted'>
|
||||||
<Col className='ps-0 mb-4' md={md} lg={lg}>
|
<Col className='ps-0 mb-4' md>
|
||||||
<AccordianItem
|
<AccordianItem
|
||||||
header={`Which wallets can I use to ${(text || 'Login').toLowerCase()}?`}
|
header={`Which wallets can I use to ${(text || 'Login').toLowerCase()}?`}
|
||||||
body={
|
body={
|
||||||
@ -92,7 +92,7 @@ function LightningExplainer ({ text, children, backButton, md = 12, lg = 6 }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col md={md} lg={lg} className='mx-auto' style={{ maxWidth: '300px' }}>
|
<Col md className='mx-auto' style={{ maxWidth: '300px' }}>
|
||||||
{children}
|
{children}
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@ -101,9 +101,9 @@ function LightningExplainer ({ text, children, backButton, md = 12, lg = 6 }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LightningAuthWithExplainer ({ text, callbackUrl, multiAuth, backButton = true, md = 12, lg = 6 }) {
|
export function LightningAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
|
||||||
return (
|
return (
|
||||||
<LightningExplainer text={text} backButton={backButton} md={md} lg={lg}>
|
<LightningExplainer text={text}>
|
||||||
<LightningAuth callbackUrl={callbackUrl} multiAuth={multiAuth} />
|
<LightningAuth callbackUrl={callbackUrl} multiAuth={multiAuth} />
|
||||||
</LightningExplainer>
|
</LightningExplainer>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -13,11 +13,16 @@ export class LightningProvider extends React.Component {
|
|||||||
* @returns boolean indicating whether the strike actually happened, based on user preferences
|
* @returns boolean indicating whether the strike actually happened, based on user preferences
|
||||||
*/
|
*/
|
||||||
strike = () => {
|
strike = () => {
|
||||||
|
const should = window.localStorage.getItem('lnAnimate') || 'yes'
|
||||||
|
if (should === 'yes') {
|
||||||
this.setState(state => {
|
this.setState(state => {
|
||||||
return {
|
return {
|
||||||
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
|
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
unstrike = (index) => {
|
unstrike = (index) => {
|
||||||
@ -35,12 +35,3 @@
|
|||||||
.linkBoxParent img {
|
.linkBoxParent img {
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.linkBoxParent h1 a,
|
|
||||||
.linkBoxParent h2 a,
|
|
||||||
.linkBoxParent h3 a,
|
|
||||||
.linkBoxParent h4 a,
|
|
||||||
.linkBoxParent h5 a,
|
|
||||||
.linkBoxParent h6 a {
|
|
||||||
pointer-events: none !important;
|
|
||||||
}
|
|
||||||
|
|||||||
62
components/log-message.js
Normal file
62
components/log-message.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { timeSince } from '@/lib/time'
|
||||||
|
import styles from '@/styles/log.module.css'
|
||||||
|
import { Fragment, useState } from 'react'
|
||||||
|
|
||||||
|
export default function LogMessage ({ showWallet, wallet, level, message, context, ts }) {
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
|
let className
|
||||||
|
switch (level.toLowerCase()) {
|
||||||
|
case 'ok':
|
||||||
|
case 'success':
|
||||||
|
level = 'ok'
|
||||||
|
className = 'text-success'; break
|
||||||
|
case 'error':
|
||||||
|
className = 'text-danger'; break
|
||||||
|
case 'warn':
|
||||||
|
className = 'text-warning'; break
|
||||||
|
default:
|
||||||
|
className = 'text-info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = context
|
||||||
|
? Object.keys(context)
|
||||||
|
.filter(key => !['send', 'recv', 'status'].includes(key))
|
||||||
|
.reduce((obj, key) => {
|
||||||
|
obj[key] = context[key]
|
||||||
|
return obj
|
||||||
|
}, {})
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const hasContext = context && Object.keys(filtered).length > 0
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (hasContext) { setShow(show => !show) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = hasContext ? { cursor: 'pointer' } : { cursor: 'inherit' }
|
||||||
|
const indicator = hasContext ? (show ? '-' : '+') : <></>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr className={styles.tableRow} onClick={handleClick} style={style}>
|
||||||
|
<td className={styles.timestamp}>{timeSince(new Date(ts))}</td>
|
||||||
|
{showWallet ? <td className={styles.wallet}>[{wallet}]</td> : <td className='mx-1' />}
|
||||||
|
<td className={`${styles.level} ${className}`}>{level}</td>
|
||||||
|
<td>{message}</td>
|
||||||
|
<td>{indicator}</td>
|
||||||
|
</tr>
|
||||||
|
{show && hasContext && Object.entries(filtered)
|
||||||
|
.map(([key, value], i) => {
|
||||||
|
const last = i === Object.keys(filtered).length - 1
|
||||||
|
return (
|
||||||
|
<tr className={styles.line} key={i}>
|
||||||
|
<td />
|
||||||
|
<td className={last ? 'pb-2 pe-1' : 'pe-1'} colSpan='2'>{key}</td>
|
||||||
|
<td className={last ? 'text-break pb-2' : 'text-break'}>{value}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
components/logger.js
Normal file
119
components/logger.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useMe } from './me'
|
||||||
|
import fancyNames from '@/lib/fancy-names.json'
|
||||||
|
|
||||||
|
const generateFancyName = () => {
|
||||||
|
// 100 adjectives * 100 nouns * 10000 = 100M possible names
|
||||||
|
const pickRandom = (array) => array[Math.floor(Math.random() * array.length)]
|
||||||
|
const adj = pickRandom(fancyNames.adjectives)
|
||||||
|
const noun = pickRandom(fancyNames.nouns)
|
||||||
|
const id = Math.floor(Math.random() * fancyNames.maxSuffix)
|
||||||
|
return `${adj}-${noun}-${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectOS () {
|
||||||
|
if (!window.navigator) return ''
|
||||||
|
|
||||||
|
const userAgent = window.navigator.userAgent
|
||||||
|
const platform = window.navigator.userAgentData?.platform || window.navigator.platform
|
||||||
|
const macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K']
|
||||||
|
const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE']
|
||||||
|
const iosPlatforms = ['iPhone', 'iPad', 'iPod']
|
||||||
|
let os = null
|
||||||
|
|
||||||
|
if (macosPlatforms.indexOf(platform) !== -1) {
|
||||||
|
os = 'Mac OS'
|
||||||
|
} else if (iosPlatforms.indexOf(platform) !== -1) {
|
||||||
|
os = 'iOS'
|
||||||
|
} else if (windowsPlatforms.indexOf(platform) !== -1) {
|
||||||
|
os = 'Windows'
|
||||||
|
} else if (/Android/.test(userAgent)) {
|
||||||
|
os = 'Android'
|
||||||
|
} else if (/Linux/.test(platform)) {
|
||||||
|
os = 'Linux'
|
||||||
|
}
|
||||||
|
|
||||||
|
return os
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoggerContext = createContext()
|
||||||
|
|
||||||
|
export const LoggerProvider = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<ServiceWorkerLoggerProvider>
|
||||||
|
{children}
|
||||||
|
</ServiceWorkerLoggerProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServiceWorkerLoggerContext = createContext()
|
||||||
|
|
||||||
|
function ServiceWorkerLoggerProvider ({ children }) {
|
||||||
|
const { me } = useMe()
|
||||||
|
const [name, setName] = useState()
|
||||||
|
const [os, setOS] = useState()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let name = window.localStorage.getItem('fancy-name')
|
||||||
|
if (!name) {
|
||||||
|
name = generateFancyName()
|
||||||
|
window.localStorage.setItem('fancy-name', name)
|
||||||
|
}
|
||||||
|
setName(name)
|
||||||
|
setOS(detectOS())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const log = useCallback(level => {
|
||||||
|
return async (message, context) => {
|
||||||
|
if (!me || !me.privates?.diagnostics) return
|
||||||
|
const env = {
|
||||||
|
userAgent: window.navigator.userAgent,
|
||||||
|
// os may not be initialized yet
|
||||||
|
os: os || detectOS()
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
level,
|
||||||
|
env,
|
||||||
|
// name may be undefined if it wasn't stored in local storage yet
|
||||||
|
// we fallback to local storage since on page reloads, the name may wasn't fetched from local storage yet
|
||||||
|
name: name || window.localStorage.getItem('fancy-name'),
|
||||||
|
message,
|
||||||
|
context
|
||||||
|
}
|
||||||
|
await fetch('/api/log', {
|
||||||
|
method: 'post',
|
||||||
|
headers: {
|
||||||
|
'Content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).catch(console.error)
|
||||||
|
}
|
||||||
|
}, [me?.privates?.diagnostics, name, os])
|
||||||
|
|
||||||
|
const logger = useMemo(() => ({
|
||||||
|
info: log('info'),
|
||||||
|
warn: log('warn'),
|
||||||
|
error: log('error'),
|
||||||
|
name
|
||||||
|
}), [log, name])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// for communication between app and service worker
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
navigator?.serviceWorker?.controller?.postMessage({ action: 'MESSAGE_PORT' }, [channel.port2])
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
const { message, level, context } = Object.assign({ level: 'info' }, event.data)
|
||||||
|
logger[level](message, context)
|
||||||
|
}
|
||||||
|
}, [logger])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ServiceWorkerLoggerContext.Provider value={logger}>
|
||||||
|
{children}
|
||||||
|
</ServiceWorkerLoggerContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServiceWorkerLogger () {
|
||||||
|
return useContext(ServiceWorkerLoggerContext)
|
||||||
|
}
|
||||||
@ -12,7 +12,6 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
|||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
import * as cookie from 'cookie'
|
import * as cookie from 'cookie'
|
||||||
import { cookieOptions } from '@/lib/auth'
|
import { cookieOptions } from '@/lib/auth'
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
export function EmailLoginForm ({ text, callbackUrl, multiAuth }) {
|
export function EmailLoginForm ({ text, callbackUrl, multiAuth }) {
|
||||||
const disabled = multiAuth
|
const disabled = multiAuth
|
||||||
@ -53,26 +52,12 @@ const authErrorMessages = {
|
|||||||
default: 'Auth failed. Try again or choose a different method.'
|
default: 'Auth failed. Try again or choose a different method.'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authErrorMessage (error, signin) {
|
export function authErrorMessage (error) {
|
||||||
if (!error) return null
|
return error && (authErrorMessages[error] ?? authErrorMessages.default)
|
||||||
|
|
||||||
const message = error && (authErrorMessages[error] ?? authErrorMessages.default)
|
|
||||||
// workaround for signin/signup awareness due to missing support from next-auth
|
|
||||||
if (signin) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{message}
|
|
||||||
<br />
|
|
||||||
If you are new to Stacker News, please <Link className='fw-bold' href='/signup'>sign up</Link> first.
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer, signin }) {
|
export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer, signin }) {
|
||||||
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error, signin))
|
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error))
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// signup/signin awareness cookie
|
// signup/signin awareness cookie
|
||||||
|
|||||||
@ -4,12 +4,6 @@ import BackArrow from '@/svgs/arrow-left-line.svg'
|
|||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import ActionDropdown from './action-dropdown'
|
import ActionDropdown from './action-dropdown'
|
||||||
|
|
||||||
export class ModalClosedError extends Error {
|
|
||||||
constructor () {
|
|
||||||
super('modal closed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShowModalContext = createContext(() => null)
|
export const ShowModalContext = createContext(() => null)
|
||||||
|
|
||||||
export function ShowModalProvider ({ children }) {
|
export function ShowModalProvider ({ children }) {
|
||||||
|
|||||||
@ -7,24 +7,24 @@ 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 } from '../../lib/constants'
|
import { USER_ID } from '../../lib/constants'
|
||||||
|
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'
|
||||||
import { abbrNum } from '../../lib/format'
|
import { abbrNum } from '../../lib/format'
|
||||||
import { useServiceWorker } from '../serviceworker'
|
import { useServiceWorker } from '../serviceworker'
|
||||||
import { signOut } from 'next-auth/react'
|
import { signOut } from 'next-auth/react'
|
||||||
import Badges from '../badge'
|
import Badges from '../badge'
|
||||||
|
import { randInRange } from '../../lib/rand'
|
||||||
|
import { useLightning } from '../lightning'
|
||||||
import LightningIcon from '../../svgs/bolt.svg'
|
import LightningIcon from '../../svgs/bolt.svg'
|
||||||
import SearchIcon from '../../svgs/search-line.svg'
|
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 { useWallets } from '@/wallets/client/hooks'
|
import { useWallets } from '@/wallets/index'
|
||||||
import { useWalletIndicator } from '@/wallets/client/hooks'
|
import SwitchAccountList, { useAccounts } from '@/components/account'
|
||||||
import SwitchAccountList, { nextAccount, useAccounts } from '@/components/account'
|
|
||||||
import { useShowModal } from '@/components/modal'
|
import { useShowModal } from '@/components/modal'
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import Head from 'next/head'
|
|
||||||
|
|
||||||
export function Brand ({ className }) {
|
export function Brand ({ className }) {
|
||||||
return (
|
return (
|
||||||
<Link href='/' passHref legacyBehavior>
|
<Link href='/' passHref legacyBehavior>
|
||||||
@ -164,34 +164,8 @@ export function NavWalletSummary ({ className }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Indicator = ({ superscript }) => {
|
|
||||||
if (superscript) {
|
|
||||||
return (
|
|
||||||
<span className='d-inline-block p-1'>
|
|
||||||
<span
|
|
||||||
className='position-absolute p-1 bg-secondary'
|
|
||||||
style={{ top: '5px', right: '0px', height: '5px', width: '5px' }}
|
|
||||||
>
|
|
||||||
<span className='invisible'>{' '}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='p-1 d-inline-block bg-secondary ms-1'>
|
|
||||||
<span className='invisible'>{' '}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MeDropdown ({ me, dropNavKey }) {
|
export function MeDropdown ({ me, dropNavKey }) {
|
||||||
if (!me) return null
|
if (!me) return null
|
||||||
|
|
||||||
const profileIndicator = !me.bioId
|
|
||||||
const walletIndicator = useWalletIndicator()
|
|
||||||
const indicator = profileIndicator || walletIndicator
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=''>
|
<div className=''>
|
||||||
<Dropdown className={styles.dropdown} align='end'>
|
<Dropdown className={styles.dropdown} align='end'>
|
||||||
@ -199,7 +173,12 @@ export function MeDropdown ({ me, dropNavKey }) {
|
|||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
<Nav.Link eventKey={me.name} as='span' className='p-0 position-relative'>
|
<Nav.Link eventKey={me.name} as='span' className='p-0 position-relative'>
|
||||||
{`@${me.name}`}
|
{`@${me.name}`}
|
||||||
{indicator && <Indicator superscript />}
|
{!me.bioId &&
|
||||||
|
<span className='d-inline-block p-1'>
|
||||||
|
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px', height: '5px', width: '5px' }}>
|
||||||
|
<span className='invisible'>{' '}</span>
|
||||||
|
</span>
|
||||||
|
</span>}
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
<Badges user={me} />
|
<Badges user={me} />
|
||||||
</div>
|
</div>
|
||||||
@ -208,17 +187,17 @@ export function MeDropdown ({ me, dropNavKey }) {
|
|||||||
<Link href={'/' + me.name} passHref legacyBehavior>
|
<Link href={'/' + me.name} passHref legacyBehavior>
|
||||||
<Dropdown.Item active={me.name === dropNavKey}>
|
<Dropdown.Item active={me.name === dropNavKey}>
|
||||||
profile
|
profile
|
||||||
{profileIndicator && <Indicator />}
|
{me && !me.bioId &&
|
||||||
|
<div className='p-1 d-inline-block bg-secondary ms-1'>
|
||||||
|
<span className='invisible'>{' '}</span>
|
||||||
|
</div>}
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
|
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
|
||||||
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
|
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href='/wallets' passHref legacyBehavior>
|
<Link href='/wallets' passHref legacyBehavior>
|
||||||
<Dropdown.Item eventKey='wallets'>
|
<Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
|
||||||
wallets
|
|
||||||
{walletIndicator && <Indicator />}
|
|
||||||
</Dropdown.Item>
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link href='/credits' passHref legacyBehavior>
|
<Link href='/credits' passHref legacyBehavior>
|
||||||
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
|
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
|
||||||
@ -293,7 +272,8 @@ export default function LoginButton () {
|
|||||||
|
|
||||||
function LogoutObstacle ({ onClose }) {
|
function LogoutObstacle ({ onClose }) {
|
||||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||||
// const { removeLocalWallets } = useWallets()
|
const { removeLocalWallets } = useWallets()
|
||||||
|
const { nextAccount } = useAccounts()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -324,6 +304,8 @@ function LogoutObstacle ({ onClose }) {
|
|||||||
await togglePushSubscription().catch(console.error)
|
await togglePushSubscription().catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeLocalWallets()
|
||||||
|
|
||||||
await signOut({ callbackUrl: '/' })
|
await signOut({ callbackUrl: '/' })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -358,7 +340,7 @@ export function LogoutDropdownItem ({ handleClose }) {
|
|||||||
|
|
||||||
function SwitchAccountButton ({ handleClose }) {
|
function SwitchAccountButton ({ handleClose }) {
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const accounts = useAccounts()
|
const { accounts } = useAccounts()
|
||||||
|
|
||||||
if (accounts.length === 0) return null
|
if (accounts.length === 0) return null
|
||||||
|
|
||||||
@ -396,6 +378,18 @@ export function LoginButtons ({ handleClose }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AnonDropdown ({ path }) {
|
export function AnonDropdown ({ path }) {
|
||||||
|
const strike = useLightning()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.localStorage.getItem('striked')) {
|
||||||
|
const to = setTimeout(() => {
|
||||||
|
strike()
|
||||||
|
window.localStorage.setItem('striked', 'yep')
|
||||||
|
}, randInRange(3000, 10000))
|
||||||
|
return () => clearTimeout(to)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='position-relative'>
|
<div className='position-relative'>
|
||||||
<Dropdown className={styles.dropdown} align='end' autoClose>
|
<Dropdown className={styles.dropdown} align='end' autoClose>
|
||||||
|
|||||||
@ -2,12 +2,11 @@ import { useState } from 'react'
|
|||||||
import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap'
|
import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap'
|
||||||
import { MEDIA_URL } from '@/lib/constants'
|
import { MEDIA_URL } from '@/lib/constants'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Indicator, LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
|
import { LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
|
||||||
import AnonIcon from '@/svgs/spy-fill.svg'
|
import AnonIcon from '@/svgs/spy-fill.svg'
|
||||||
import styles from './footer.module.css'
|
import styles from './footer.module.css'
|
||||||
import canvasStyles from './offcanvas.module.css'
|
import canvasStyles from './offcanvas.module.css'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { useWalletIndicator } from '@/wallets/client/hooks'
|
|
||||||
|
|
||||||
export default function OffCanvas ({ me, dropNavKey }) {
|
export default function OffCanvas ({ me, dropNavKey }) {
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
@ -26,9 +25,6 @@ export default function OffCanvas ({ me, dropNavKey }) {
|
|||||||
)
|
)
|
||||||
: <span className='text-muted pointer'><AnonIcon onClick={onClick} width='22' height='22' /></span>
|
: <span className='text-muted pointer'><AnonIcon onClick={onClick} width='22' height='22' /></span>
|
||||||
|
|
||||||
const profileIndicator = me && !me.bioId
|
|
||||||
const walletIndicator = useWalletIndicator()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MeImage onClick={handleShow} />
|
<MeImage onClick={handleShow} />
|
||||||
@ -54,17 +50,17 @@ export default function OffCanvas ({ me, dropNavKey }) {
|
|||||||
<Link href={'/' + me.name} passHref legacyBehavior>
|
<Link href={'/' + me.name} passHref legacyBehavior>
|
||||||
<Dropdown.Item active={me.name === dropNavKey}>
|
<Dropdown.Item active={me.name === dropNavKey}>
|
||||||
profile
|
profile
|
||||||
{profileIndicator && <Indicator />}
|
{me && !me.bioId &&
|
||||||
|
<div className='p-1 d-inline-block bg-secondary ms-1'>
|
||||||
|
<span className='invisible'>{' '}</span>
|
||||||
|
</div>}
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
|
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
|
||||||
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
|
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href='/wallets' passHref legacyBehavior>
|
<Link href='/wallets' passHref legacyBehavior>
|
||||||
<Dropdown.Item eventKey='wallets'>
|
<Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
|
||||||
wallets
|
|
||||||
{walletIndicator && <Indicator />}
|
|
||||||
</Dropdown.Item>
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link href='/credits' passHref legacyBehavior>
|
<Link href='/credits' passHref legacyBehavior>
|
||||||
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
|
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
|
||||||
|
|||||||
@ -58,9 +58,7 @@ function Notification ({ n, fresh }) {
|
|||||||
(type === 'InvoicePaid' && (n.invoice.nostr ? <NostrZap n={n} /> : <InvoicePaid n={n} />)) ||
|
(type === 'InvoicePaid' && (n.invoice.nostr ? <NostrZap n={n} /> : <InvoicePaid n={n} />)) ||
|
||||||
(type === 'WithdrawlPaid' && <WithdrawlPaid n={n} />) ||
|
(type === 'WithdrawlPaid' && <WithdrawlPaid n={n} />) ||
|
||||||
(type === 'Referral' && <Referral n={n} />) ||
|
(type === 'Referral' && <Referral n={n} />) ||
|
||||||
(type === 'CowboyHat' && <CowboyHat n={n} />) ||
|
(type === 'Streak' && <Streak n={n} />) ||
|
||||||
(['NewHorse', 'LostHorse'].includes(type) && <Horse n={n} />) ||
|
|
||||||
(['NewGun', 'LostGun'].includes(type) && <Gun n={n} />) ||
|
|
||||||
(type === 'Votification' && <Votification n={n} />) ||
|
(type === 'Votification' && <Votification n={n} />) ||
|
||||||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
|
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
|
||||||
(type === 'Mention' && <Mention n={n} />) ||
|
(type === 'Mention' && <Mention n={n} />) ||
|
||||||
@ -114,9 +112,7 @@ function NoteHeader ({ color, children, big }) {
|
|||||||
function NoteItem ({ item, ...props }) {
|
function NoteItem ({ item, ...props }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{item.isJob
|
{item.title
|
||||||
? <ItemJob item={item} {...props} />
|
|
||||||
: item.title
|
|
||||||
? <Item item={item} itemClassName='pt-0' {...props} />
|
? <Item item={item} itemClassName='pt-0' {...props} />
|
||||||
: (
|
: (
|
||||||
<RootProvider root={item.root}>
|
<RootProvider root={item.root}>
|
||||||
@ -167,7 +163,7 @@ const defaultOnClick = n => {
|
|||||||
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 === 'ReferralReward') return { href: '/referrals/month' }
|
||||||
if (['CowboyHat', 'NewHorse', 'LostHorse', 'NewGun', 'LostGun'].includes(type)) 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 {}
|
||||||
@ -176,64 +172,30 @@ const defaultOnClick = n => {
|
|||||||
return itemLink(n.item)
|
return itemLink(n.item)
|
||||||
}
|
}
|
||||||
|
|
||||||
function blurb (n) {
|
function Streak ({ n }) {
|
||||||
const type = n.__typename === 'CowboyHat'
|
function blurb (n) {
|
||||||
? 'COWBOY_HAT'
|
const type = n.type ?? 'COWBOY_HAT'
|
||||||
: (n.__typename.includes('Horse') ? 'HORSE' : 'GUN')
|
|
||||||
const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length)
|
const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length)
|
||||||
const lost = n.days || n.__typename.includes('Lost')
|
|
||||||
return lost ? LOST_BLURBS[type][index] : FOUND_BLURBS[type][index]
|
|
||||||
}
|
|
||||||
|
|
||||||
function CowboyHat ({ n }) {
|
|
||||||
const Icon = n.days ? BaldIcon : CowboyHatIcon
|
|
||||||
|
|
||||||
let body = ''
|
|
||||||
if (n.days) {
|
if (n.days) {
|
||||||
body = `After ${numWithUnits(n.days, {
|
return `After ${numWithUnits(n.days, {
|
||||||
abbreviate: false,
|
abbreviate: false,
|
||||||
unitSingular: 'day',
|
unitSingular: 'day',
|
||||||
unitPlural: 'days'
|
unitPlural: 'days'
|
||||||
})}, `
|
})}, ` + LOST_BLURBS[type][index]
|
||||||
}
|
}
|
||||||
|
|
||||||
body += `you ${n.days ? 'lost your' : 'found a'} cowboy hat`
|
return FOUND_BLURBS[type][index]
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = n.days
|
||||||
|
? n.type === 'GUN' ? HolsterIcon : n.type === 'HORSE' ? SaddleIcon : BaldIcon
|
||||||
|
: n.type === 'GUN' ? GunIcon : n.type === 'HORSE' ? HorseIcon : CowboyHatIcon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='d-flex'>
|
<div className='d-flex'>
|
||||||
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
|
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
|
||||||
<div className='ms-1 p-1'>
|
<div className='ms-1 p-1'>
|
||||||
<span className='fw-bold'>{body}</span>
|
<span className='fw-bold'>you {n.days ? 'lost your' : 'found a'} {n.type.toLowerCase().replace('_', ' ')}</span>
|
||||||
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Horse ({ n }) {
|
|
||||||
const found = n.__typename.includes('New')
|
|
||||||
const Icon = found ? HorseIcon : SaddleIcon
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='d-flex'>
|
|
||||||
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
|
|
||||||
<div className='ms-1 p-1'>
|
|
||||||
<span className='fw-bold'>you {found ? 'found a' : 'lost your'} horse</span>
|
|
||||||
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Gun ({ n }) {
|
|
||||||
const found = n.__typename.includes('New')
|
|
||||||
const Icon = found ? GunIcon : HolsterIcon
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='d-flex'>
|
|
||||||
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
|
|
||||||
<div className='ms-1 p-1'>
|
|
||||||
<span className='fw-bold'>you {found ? 'found a' : 'lost your'} gun</span>
|
|
||||||
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
|
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -738,7 +700,7 @@ function Reminder ({ n }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NoteHeader color='info'>
|
<NoteHeader color='info'>
|
||||||
you requested this reminder
|
you asked to be reminded of this {n.item.title ? 'post' : 'comment'}
|
||||||
</NoteHeader>
|
</NoteHeader>
|
||||||
<NoteItem item={n.item} />
|
<NoteItem item={n.item} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
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'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
@ -6,10 +7,9 @@ import { numWithUnits } from '@/lib/format'
|
|||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { useRoot } from './root'
|
import { useRoot } from './root'
|
||||||
import { ActCanceledError, useAct } from './item-act'
|
import { ActCanceledError, useAct } from './item-act'
|
||||||
import { useAnimation } from '@/components/animation'
|
import { useLightning } from './lightning'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { useHasSendWallet } from '@/wallets/client/hooks'
|
import { useSendWallets } from '@/wallets/index'
|
||||||
import { Form, SubmitButton } from './form'
|
|
||||||
|
|
||||||
export const payBountyCacheMods = {
|
export const payBountyCacheMods = {
|
||||||
onPaid: (cache, { data }) => {
|
onPaid: (cache, { data }) => {
|
||||||
@ -48,11 +48,11 @@ export default function PayBounty ({ children, item }) {
|
|||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const root = useRoot()
|
const root = useRoot()
|
||||||
const animate = useAnimation()
|
const strike = useLightning()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const hasSendWallet = useHasSendWallet()
|
const wallets = useSendWallets()
|
||||||
|
|
||||||
const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet }
|
const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet: wallets.length > 0 }
|
||||||
const act = useAct({
|
const act = useAct({
|
||||||
variables,
|
variables,
|
||||||
optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } },
|
optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } },
|
||||||
@ -61,7 +61,7 @@ export default function PayBounty ({ children, item }) {
|
|||||||
|
|
||||||
const handlePayBounty = async onCompleted => {
|
const handlePayBounty = async onCompleted => {
|
||||||
try {
|
try {
|
||||||
animate()
|
strike()
|
||||||
const { error } = await act({ onCompleted })
|
const { error } = await act({ onCompleted })
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -90,12 +90,11 @@ export default function PayBounty ({ children, item }) {
|
|||||||
<div className='text-center fw-bold text-muted'>
|
<div className='text-center fw-bold text-muted'>
|
||||||
Pay this bounty to {item.user.name}?
|
Pay this bounty to {item.user.name}?
|
||||||
</div>
|
</div>
|
||||||
{/* initial={{ id: item.id }} is a hack to allow SubmitButton to be used as a button */}
|
<div className='text-center'>
|
||||||
<Form className='text-center' onSubmit={() => handlePayBounty(onClose)} initial={{ id: item.id }}>
|
<Button className='mt-4' variant='primary' onClick={() => handlePayBounty(onClose)}>
|
||||||
<SubmitButton className='mt-4' variant='primary' submittingText='paying...' appendText={numWithUnits(root.bounty)}>
|
pay <small>{numWithUnits(root.bounty)}</small>
|
||||||
pay
|
</Button>
|
||||||
</SubmitButton>
|
</div>
|
||||||
</Form>
|
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Checkbox, DateTimeInput, Form, Input, MarkdownInput, VariableInput } from '@/components/form'
|
import { DateTimeInput, Form, Input, MarkdownInput, VariableInput } from '@/components/form'
|
||||||
import { useApolloClient } from '@apollo/client'
|
import { useApolloClient } 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'
|
||||||
@ -30,7 +30,6 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
text: item?.text || '',
|
text: item?.text || '',
|
||||||
options: initialOptions || ['', ''],
|
options: initialOptions || ['', ''],
|
||||||
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
|
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
|
||||||
randPollOptions: item?.poll?.randPollOptions || false,
|
|
||||||
pollExpiresAt: item ? item.pollExpiresAt : datePivot(new Date(), { hours: 25 }),
|
pollExpiresAt: item ? item.pollExpiresAt : datePivot(new Date(), { hours: 25 }),
|
||||||
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
|
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
|
||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
@ -69,11 +68,6 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
label='poll expiration'
|
label='poll expiration'
|
||||||
name='pollExpiresAt'
|
name='pollExpiresAt'
|
||||||
className='pr-4'
|
className='pr-4'
|
||||||
groupClassName='mb-0'
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label={<div className='d-flex align-items-center'>randomize order of poll choices</div>}
|
|
||||||
name='randPollOptions'
|
|
||||||
/>
|
/>
|
||||||
</AdvPostForm>
|
</AdvPostForm>
|
||||||
<ItemButtonBar itemId={item?.id} />
|
<ItemButtonBar itemId={item?.id} />
|
||||||
|
|||||||
@ -110,7 +110,7 @@ export function PostForm ({ type, sub, children }) {
|
|||||||
noForm
|
noForm
|
||||||
size='medium'
|
size='medium'
|
||||||
sub={sub?.name}
|
sub={sub?.name}
|
||||||
info={sub && <TerritoryInfo sub={sub} includeLink />}
|
info={sub && <TerritoryInfo sub={sub} />}
|
||||||
hint={sub?.moderated && 'this territory is moderated'}
|
hint={sub?.moderated && 'this territory is moderated'}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
@ -176,7 +176,7 @@ export default function Post ({ sub }) {
|
|||||||
className='d-flex'
|
className='d-flex'
|
||||||
size='medium'
|
size='medium'
|
||||||
label='territory'
|
label='territory'
|
||||||
info={sub && <TerritoryInfo sub={sub} includeLink />}
|
info={sub && <TerritoryInfo sub={sub} />}
|
||||||
hint={sub?.moderated && 'this territory is moderated'}
|
hint={sub?.moderated && 'this territory is moderated'}
|
||||||
/>}
|
/>}
|
||||||
</PostForm>
|
</PostForm>
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
export default function preserveScroll (callback) {
|
|
||||||
// preserve the actual scroll position
|
|
||||||
const scrollTop = window.scrollY
|
|
||||||
|
|
||||||
// if the scroll position is at the top, we don't need to preserve it, just call the callback
|
|
||||||
if (scrollTop <= 0) {
|
|
||||||
callback()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// get a reference element at the center of the viewport to track if content is added above it
|
|
||||||
const ref = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
|
|
||||||
const refTop = ref ? ref.getBoundingClientRect().top + scrollTop : scrollTop
|
|
||||||
|
|
||||||
// observe the document for changes in height
|
|
||||||
const observer = new window.MutationObserver(() => {
|
|
||||||
// request animation frame to ensure the DOM is updated
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
// we can't proceed if we couldn't find a traceable reference element
|
|
||||||
if (!ref) {
|
|
||||||
cleanup()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// get the new position of the reference element along with the new scroll position
|
|
||||||
const newRefTop = ref ? ref.getBoundingClientRect().top + window.scrollY : window.scrollY
|
|
||||||
// has the reference element moved?
|
|
||||||
const refMoved = newRefTop - refTop
|
|
||||||
|
|
||||||
// if the reference element moved, we need to scroll to the new position
|
|
||||||
if (refMoved > 0) {
|
|
||||||
window.scrollTo({
|
|
||||||
// some browsers don't respond well to fractional scroll position, so we round up the new position to the nearest integer
|
|
||||||
top: scrollTop + Math.ceil(refMoved),
|
|
||||||
behavior: 'instant'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => cleanup(), 1000) // fallback
|
|
||||||
|
|
||||||
function cleanup () {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
observer.observe(document.body, { childList: true, subtree: true })
|
|
||||||
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
@ -13,7 +13,6 @@ import { useRoot } from './root'
|
|||||||
import { CREATE_COMMENT } from '@/fragments/paidAction'
|
import { CREATE_COMMENT } from '@/fragments/paidAction'
|
||||||
import useItemSubmit from './use-item-submit'
|
import useItemSubmit from './use-item-submit'
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import { updateAncestorsCommentCount } from '@/lib/comments'
|
|
||||||
|
|
||||||
export default forwardRef(function Reply ({
|
export default forwardRef(function Reply ({
|
||||||
item,
|
item,
|
||||||
@ -83,7 +82,17 @@ export default forwardRef(function Reply ({
|
|||||||
const ancestors = item.path.split('.')
|
const ancestors = item.path.split('.')
|
||||||
|
|
||||||
// update all ancestors
|
// update all ancestors
|
||||||
updateAncestorsCommentCount(cache, ancestors, 1)
|
ancestors.forEach(id => {
|
||||||
|
cache.modify({
|
||||||
|
id: `Item:${id}`,
|
||||||
|
fields: {
|
||||||
|
ncomments (existingNComments = 0) {
|
||||||
|
return existingNComments + 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimistic: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@ -1,26 +1,22 @@
|
|||||||
import Container from 'react-bootstrap/Container'
|
import Container from 'react-bootstrap/Container'
|
||||||
import styles from './search.module.css'
|
import styles from './search.module.css'
|
||||||
import SearchIcon from '@/svgs/search-line.svg'
|
import SearchIcon from '@/svgs/search-line.svg'
|
||||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import { Form, Input, Select, DatePicker, SubmitButton } from './form'
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
DatePicker,
|
|
||||||
SubmitButton,
|
|
||||||
useDualAutocomplete,
|
|
||||||
DualAutocompleteWrapper
|
|
||||||
} from './form'
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { whenToFrom } from '@/lib/time'
|
import { whenToFrom } from '@/lib/time'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { useField } from 'formik'
|
|
||||||
|
|
||||||
export default function Search ({ sub }) {
|
export default function Search ({ sub }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [q, setQ] = useState(router.query.q || '')
|
const [q, setQ] = useState(router.query.q || '')
|
||||||
|
const inputRef = useRef(null)
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const search = async values => {
|
const search = async values => {
|
||||||
let prefix = ''
|
let prefix = ''
|
||||||
if (sub) {
|
if (sub) {
|
||||||
@ -67,13 +63,18 @@ export default function Search ({ sub }) {
|
|||||||
onSubmit={values => search({ ...values })}
|
onSubmit={values => search({ ...values })}
|
||||||
>
|
>
|
||||||
<div className={`${styles.active} mb-3`}>
|
<div className={`${styles.active} mb-3`}>
|
||||||
<SearchInput
|
<Input
|
||||||
name='q'
|
name='q'
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
groupClassName='me-3 mb-0 flex-grow-1'
|
groupClassName='me-3 mb-0 flex-grow-1'
|
||||||
className='flex-grow-1'
|
className='flex-grow-1'
|
||||||
setOuterQ={setQ}
|
clear
|
||||||
|
innerRef={inputRef}
|
||||||
|
overrideValue={q}
|
||||||
|
onChange={async (formik, e) => {
|
||||||
|
setQ(e.target.value?.trim())
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<SubmitButton variant='primary' className={styles.search}>
|
<SubmitButton variant='primary' className={styles.search}>
|
||||||
<SearchIcon width={22} height={22} />
|
<SearchIcon width={22} height={22} />
|
||||||
@ -134,52 +135,3 @@ export default function Search ({ sub }) {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchInput ({ name, setOuterQ, ...props }) {
|
|
||||||
const [, meta, helpers] = useField(name)
|
|
||||||
const inputRef = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (meta.value !== undefined) setOuterQ(meta.value.trim())
|
|
||||||
}, [meta.value, setOuterQ])
|
|
||||||
|
|
||||||
const setCaret = useCallback(({ start, end }) => {
|
|
||||||
inputRef.current?.setSelectionRange(start, end)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({
|
|
||||||
meta,
|
|
||||||
helpers,
|
|
||||||
innerRef: inputRef,
|
|
||||||
setSelectionRange: setCaret
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleChangeWithOuter = useCallback((formik, e) => {
|
|
||||||
setOuterQ(e.target.value.trim())
|
|
||||||
handleTextChange(e)
|
|
||||||
}, [setOuterQ, handleTextChange])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='position-relative flex-grow-1'>
|
|
||||||
<DualAutocompleteWrapper
|
|
||||||
userAutocomplete={userAutocomplete}
|
|
||||||
territoryAutocomplete={territoryAutocomplete}
|
|
||||||
>
|
|
||||||
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => (
|
|
||||||
<Input
|
|
||||||
name={name}
|
|
||||||
innerRef={inputRef}
|
|
||||||
clear
|
|
||||||
autoComplete='off'
|
|
||||||
onChange={handleChangeWithOuter}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
handleKeyDown(e, userSuggestOnKeyDown, territorySuggestOnKeyDown)
|
|
||||||
}}
|
|
||||||
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DualAutocompleteWrapper>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,16 +1,20 @@
|
|||||||
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import { Workbox } from 'workbox-window'
|
import { Workbox } from 'workbox-window'
|
||||||
import { gql, useMutation } from '@apollo/client'
|
import { gql, useMutation } from '@apollo/client'
|
||||||
import { requestPersistentStorage } from './use-indexeddb'
|
import { detectOS, useServiceWorkerLogger } from './logger'
|
||||||
|
|
||||||
const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY
|
const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY
|
||||||
|
|
||||||
const ServiceWorkerContext = createContext()
|
const ServiceWorkerContext = createContext()
|
||||||
|
|
||||||
// message types for communication between app and service worker
|
// message types for communication between app and service worker
|
||||||
export const DELETE_SUBSCRIPTION = 'DELETE_SUBSCRIPTION'
|
export const MESSAGE_PORT = 'MESSAGE_PORT' // message to exchange message channel on which service worker will send messages back to app
|
||||||
export const STORE_SUBSCRIPTION = 'STORE_SUBSCRIPTION'
|
export const ACTION_PORT = 'ACTION_PORT' // message to exchange action channel on which service worker will send actions back to app
|
||||||
export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS'
|
export const SYNC_SUBSCRIPTION = 'SYNC_SUBSCRIPTION' // trigger onPushSubscriptionChange event in service worker manually
|
||||||
|
export const RESUBSCRIBE = 'RESUBSCRIBE' // trigger resubscribing to push notifications (sw -> app)
|
||||||
|
export const DELETE_SUBSCRIPTION = 'DELETE_SUBSCRIPTION' // delete subscription in IndexedDB (app -> sw)
|
||||||
|
export const STORE_SUBSCRIPTION = 'STORE_SUBSCRIPTION' // store subscription in IndexedDB (app -> sw)
|
||||||
|
export const STORE_OS = 'STORE_OS' // store OS in service worker
|
||||||
|
|
||||||
export const ServiceWorkerProvider = ({ children }) => {
|
export const ServiceWorkerProvider = ({ children }) => {
|
||||||
const [registration, setRegistration] = useState(null)
|
const [registration, setRegistration] = useState(null)
|
||||||
@ -40,6 +44,7 @@ export const ServiceWorkerProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
const logger = useServiceWorkerLogger()
|
||||||
|
|
||||||
// I am not entirely sure if this is needed since at least in Brave,
|
// I am not entirely sure if this is needed since at least in Brave,
|
||||||
// using `registration.pushManager.subscribe` also prompts the user.
|
// using `registration.pushManager.subscribe` also prompts the user.
|
||||||
@ -72,6 +77,7 @@ export const ServiceWorkerProvider = ({ children }) => {
|
|||||||
// see https://stackoverflow.com/a/69624651
|
// see https://stackoverflow.com/a/69624651
|
||||||
let pushSubscription = await registration.pushManager.subscribe(subscribeOptions)
|
let pushSubscription = await registration.pushManager.subscribe(subscribeOptions)
|
||||||
const { endpoint } = pushSubscription
|
const { endpoint } = pushSubscription
|
||||||
|
logger.info('subscribed to push notifications', { endpoint })
|
||||||
// convert keys from ArrayBuffer to string
|
// convert keys from ArrayBuffer to string
|
||||||
pushSubscription = JSON.parse(JSON.stringify(pushSubscription))
|
pushSubscription = JSON.parse(JSON.stringify(pushSubscription))
|
||||||
// Send subscription to service worker to save it so we can use it later during `pushsubscriptionchange`
|
// Send subscription to service worker to save it so we can use it later during `pushsubscriptionchange`
|
||||||
@ -80,8 +86,7 @@ export const ServiceWorkerProvider = ({ children }) => {
|
|||||||
action: STORE_SUBSCRIPTION,
|
action: STORE_SUBSCRIPTION,
|
||||||
subscription: pushSubscription
|
subscription: pushSubscription
|
||||||
})
|
})
|
||||||
requestPersistentStorage()
|
logger.info('sent STORE_SUBSCRIPTION to service worker', { endpoint })
|
||||||
|
|
||||||
// send subscription to server
|
// send subscription to server
|
||||||
const variables = {
|
const variables = {
|
||||||
endpoint,
|
endpoint,
|
||||||
@ -89,21 +94,34 @@ export const ServiceWorkerProvider = ({ children }) => {
|
|||||||
auth: pushSubscription.keys.auth
|
auth: pushSubscription.keys.auth
|
||||||
}
|
}
|
||||||
await savePushSubscription({ variables })
|
await savePushSubscription({ variables })
|
||||||
|
logger.info('sent push subscription to server', { endpoint })
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribeFromPushNotifications = async (subscription) => {
|
const unsubscribeFromPushNotifications = async (subscription) => {
|
||||||
await subscription.unsubscribe()
|
await subscription.unsubscribe()
|
||||||
const { endpoint } = subscription
|
const { endpoint } = subscription
|
||||||
|
logger.info('unsubscribed from push notifications', { endpoint })
|
||||||
await deletePushSubscription({ variables: { endpoint } })
|
await deletePushSubscription({ variables: { endpoint } })
|
||||||
|
// also delete push subscription in IndexedDB so we can tell if the user disabled push subscriptions
|
||||||
|
// or we lost the push subscription due to a bug
|
||||||
navigator.serviceWorker.controller.postMessage({ action: DELETE_SUBSCRIPTION })
|
navigator.serviceWorker.controller.postMessage({ action: DELETE_SUBSCRIPTION })
|
||||||
|
logger.info('deleted push subscription from server', { endpoint })
|
||||||
}
|
}
|
||||||
|
|
||||||
const togglePushSubscription = useCallback(async () => {
|
const togglePushSubscription = useCallback(async () => {
|
||||||
const pushSubscription = await registration.pushManager.getSubscription()
|
const pushSubscription = await registration.pushManager.getSubscription()
|
||||||
if (pushSubscription) {
|
if (pushSubscription) {
|
||||||
return await unsubscribeFromPushNotifications(pushSubscription)
|
return unsubscribeFromPushNotifications(pushSubscription)
|
||||||
}
|
}
|
||||||
await subscribeToPushNotifications()
|
return subscribeToPushNotifications().then(async () => {
|
||||||
|
// request persistent storage: https://web.dev/learn/pwa/offline-data#data_persistence
|
||||||
|
const persisted = await navigator?.storage?.persisted?.()
|
||||||
|
if (!persisted && navigator?.storage?.persist) {
|
||||||
|
return navigator.storage.persist().then(persistent => {
|
||||||
|
logger.info('persistent storage:', persistent)
|
||||||
|
}).catch(logger.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -115,15 +133,37 @@ export const ServiceWorkerProvider = ({ children }) => {
|
|||||||
setPermission({ notification: 'Notification' in window ? window.Notification.permission : 'denied' })
|
setPermission({ notification: 'Notification' in window ? window.Notification.permission : 'denied' })
|
||||||
|
|
||||||
if (!('serviceWorker' in navigator)) {
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
logger.info('device does not support service worker')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const wb = new Workbox('/sw.js', { scope: '/' })
|
const wb = new Workbox('/sw.js', { scope: '/' })
|
||||||
wb.register().then(registration => {
|
wb.register().then(registration => {
|
||||||
|
logger.info('service worker registration successful')
|
||||||
setRegistration(registration)
|
setRegistration(registration)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// wait until successful registration
|
||||||
|
if (!registration) return
|
||||||
|
// setup channel between app and service worker
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
navigator?.serviceWorker?.controller?.postMessage({ action: ACTION_PORT }, [channel.port2])
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data.action === RESUBSCRIBE && permission.notification === 'granted') {
|
||||||
|
return subscribeToPushNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// since (a lot of) browsers don't support the pushsubscriptionchange event,
|
||||||
|
// we sync with server manually by checking on every page reload if the push subscription changed.
|
||||||
|
// see https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
|
||||||
|
navigator?.serviceWorker?.controller?.postMessage?.({ action: STORE_OS, os: detectOS() })
|
||||||
|
logger.info('sent STORE_OS to service worker: ', detectOS())
|
||||||
|
navigator?.serviceWorker?.controller?.postMessage?.({ action: SYNC_SUBSCRIPTION })
|
||||||
|
logger.info('sent SYNC_SUBSCRIPTION to service worker')
|
||||||
|
}, [registration, permission.notification])
|
||||||
|
|
||||||
const contextValue = useMemo(() => ({
|
const contextValue = useMemo(() => ({
|
||||||
registration,
|
registration,
|
||||||
support,
|
support,
|
||||||
@ -139,10 +179,6 @@ export const ServiceWorkerProvider = ({ children }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearNotifications () {
|
|
||||||
return navigator.serviceWorker?.controller?.postMessage({ action: CLEAR_NOTIFICATIONS })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useServiceWorker () {
|
export function useServiceWorker () {
|
||||||
return useContext(ServiceWorkerContext)
|
return useContext(ServiceWorkerContext)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export const SnowProvider = ({ children }) => {
|
|||||||
const [flakes, setFlakes] = useState(Array(1024))
|
const [flakes, setFlakes] = useState(Array(1024))
|
||||||
|
|
||||||
const snow = useCallback(() => {
|
const snow = useCallback(() => {
|
||||||
|
const should = window.localStorage.getItem('lnAnimate') || 'yes'
|
||||||
|
if (should === 'yes') {
|
||||||
// amount of flakes to add
|
// amount of flakes to add
|
||||||
const n = Math.floor(randInRange(5, 30))
|
const n = Math.floor(randInRange(5, 30))
|
||||||
const newFlakes = [...flakes]
|
const newFlakes = [...flakes]
|
||||||
@ -21,7 +23,10 @@ export const SnowProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
setStartIndex(i % MAX_FLAKES)
|
setStartIndex(i % MAX_FLAKES)
|
||||||
setFlakes(newFlakes)
|
setFlakes(newFlakes)
|
||||||
}, [flakes, startIndex])
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [setFlakes, startIndex])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SnowContext.Provider value={snow}>
|
<SnowContext.Provider value={snow}>
|
||||||
@ -20,25 +20,6 @@ export default function SubscribeDropdownItem ({ item: { id, meSubscription } })
|
|||||||
},
|
},
|
||||||
optimistic: true
|
optimistic: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const unsubscribed = !subscribeItem.meSubscription
|
|
||||||
if (!unsubscribed) return
|
|
||||||
|
|
||||||
const cacheState = cache.extract()
|
|
||||||
Object.keys(cacheState)
|
|
||||||
.filter(key => key.startsWith('Item:'))
|
|
||||||
.forEach(key => {
|
|
||||||
cache.modify({
|
|
||||||
id: key,
|
|
||||||
fields: {
|
|
||||||
meSubscription: (existing, { readField }) => {
|
|
||||||
const path = readField('path')
|
|
||||||
return !path || !path.includes(id) ? existing : false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
optimistic: true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { createContext, useContext } from 'react'
|
|
||||||
import { Badge, Button, CardFooter, Dropdown } from 'react-bootstrap'
|
import { Badge, Button, CardFooter, Dropdown } from 'react-bootstrap'
|
||||||
import { AccordianCard } from './accordian-item'
|
import { AccordianCard } from './accordian-item'
|
||||||
import TerritoryPaymentDue, { TerritoryBillingLine } from './territory-payment-due'
|
import TerritoryPaymentDue, { TerritoryBillingLine } from './territory-payment-due'
|
||||||
@ -14,16 +13,6 @@ import { useToast } from './toast'
|
|||||||
import ActionDropdown from './action-dropdown'
|
import ActionDropdown from './action-dropdown'
|
||||||
import { TerritoryTransferDropdownItem } from './territory-transfer'
|
import { TerritoryTransferDropdownItem } from './territory-transfer'
|
||||||
|
|
||||||
const SubscribeTerritoryContext = createContext({ refetchQueries: [] })
|
|
||||||
|
|
||||||
export const SubscribeTerritoryContextProvider = ({ children, value }) => (
|
|
||||||
<SubscribeTerritoryContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</SubscribeTerritoryContext.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const useSubscribeTerritoryContext = () => useContext(SubscribeTerritoryContext)
|
|
||||||
|
|
||||||
export function TerritoryDetails ({ sub, children }) {
|
export function TerritoryDetails ({ sub, children }) {
|
||||||
return (
|
return (
|
||||||
<AccordianCard
|
<AccordianCard
|
||||||
@ -53,10 +42,9 @@ export function TerritoryInfoSkeleton ({ children, className }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerritoryInfo ({ sub, includeLink }) {
|
export function TerritoryInfo ({ sub }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{includeLink && <Link href={`/~${sub.name}`}>{sub.name}</Link>}
|
|
||||||
<div className='py-2'>
|
<div className='py-2'>
|
||||||
<Text>{sub.desc}</Text>
|
<Text>{sub.desc}</Text>
|
||||||
</div>
|
</div>
|
||||||
@ -160,15 +148,12 @@ export default function TerritoryHeader ({ sub }) {
|
|||||||
|
|
||||||
export function MuteSubDropdownItem ({ item, sub }) {
|
export function MuteSubDropdownItem ({ item, sub }) {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const { refetchQueries } = useSubscribeTerritoryContext()
|
|
||||||
|
|
||||||
const [toggleMuteSub] = useMutation(
|
const [toggleMuteSub] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation toggleMuteSub($name: String!) {
|
mutation toggleMuteSub($name: String!) {
|
||||||
toggleMuteSub(name: $name)
|
toggleMuteSub(name: $name)
|
||||||
}`, {
|
}`, {
|
||||||
refetchQueries,
|
|
||||||
awaitRefetchQueries: true,
|
|
||||||
update (cache, { data: { toggleMuteSub } }) {
|
update (cache, { data: { toggleMuteSub } }) {
|
||||||
cache.modify({
|
cache.modify({
|
||||||
id: `Sub:{"name":"${sub.name}"}`,
|
id: `Sub:{"name":"${sub.name}"}`,
|
||||||
@ -227,14 +212,11 @@ export function PinSubDropdownItem ({ item: { id, position } }) {
|
|||||||
|
|
||||||
export function ToggleSubSubscriptionDropdownItem ({ sub: { name, meSubscription } }) {
|
export function ToggleSubSubscriptionDropdownItem ({ sub: { name, meSubscription } }) {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const { refetchQueries } = useSubscribeTerritoryContext()
|
|
||||||
const [toggleSubSubscription] = useMutation(
|
const [toggleSubSubscription] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation toggleSubSubscription($name: String!) {
|
mutation toggleSubSubscription($name: String!) {
|
||||||
toggleSubSubscription(name: $name)
|
toggleSubSubscription(name: $name)
|
||||||
}`, {
|
}`, {
|
||||||
refetchQueries,
|
|
||||||
awaitRefetchQueries: true,
|
|
||||||
update (cache, { data: { toggleSubSubscription } }) {
|
update (cache, { data: { toggleSubSubscription } }) {
|
||||||
cache.modify({
|
cache.modify({
|
||||||
id: `Sub:{"name":"${name}"}`,
|
id: `Sub:{"name":"${name}"}`,
|
||||||
|
|||||||
@ -5,10 +5,8 @@ import React, { useEffect, useMemo, useState } from 'react'
|
|||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import MoreFooter from './more-footer'
|
import MoreFooter from './more-footer'
|
||||||
import { useData } from './use-data'
|
import { useData } from './use-data'
|
||||||
import { useMe } from './me'
|
|
||||||
import Info from './info'
|
import Info from './info'
|
||||||
import ActionDropdown from './action-dropdown'
|
import { TerritoryInfo } from './territory-header'
|
||||||
import { TerritoryInfo, ToggleSubSubscriptionDropdownItem, MuteSubDropdownItem } from './territory-header'
|
|
||||||
|
|
||||||
// all of this nonsense is to show the stat we are sorting by first
|
// all of this nonsense is to show the stat we are sorting by first
|
||||||
const Revenue = ({ sub }) => (sub.optional.revenue !== null && <span>{abbrNum(sub.optional.revenue)} revenue</span>)
|
const Revenue = ({ sub }) => (sub.optional.revenue !== null && <span>{abbrNum(sub.optional.revenue)} revenue</span>)
|
||||||
@ -37,17 +35,16 @@ function separate (arr, separator) {
|
|||||||
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, separator] : [x])
|
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, separator] : [x])
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TerritoryList ({ ssrData, query, variables, destructureData, rank, subActionDropdown, statCompsProp = STAT_COMPONENTS }) {
|
export default function TerritoryList ({ ssrData, query, variables, destructureData, rank }) {
|
||||||
const { data, fetchMore } = useQuery(query, { variables })
|
const { data, fetchMore } = useQuery(query, { variables })
|
||||||
const dat = useData(data, ssrData)
|
const dat = useData(data, ssrData)
|
||||||
const { me } = useMe()
|
const [statComps, setStatComps] = useState(separate(STAT_COMPONENTS, Separator))
|
||||||
const [statComps, setStatComps] = useState(separate(statCompsProp, Separator))
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// shift the stat we are sorting by to the front
|
// shift the stat we are sorting by to the front
|
||||||
const comps = [...statCompsProp]
|
const comps = [...STAT_COMPONENTS]
|
||||||
setStatComps(separate([...comps.splice(STAT_POS[variables?.by || 0], 1), ...comps], Separator))
|
setStatComps(separate([...comps.splice(STAT_POS[variables?.by || 0], 1), ...comps], Separator))
|
||||||
}, [variables?.by], statCompsProp)
|
}, [variables?.by])
|
||||||
|
|
||||||
const { subs, cursor } = useMemo(() => {
|
const { subs, cursor } = useMemo(() => {
|
||||||
if (!dat) return {}
|
if (!dat) return {}
|
||||||
@ -80,12 +77,6 @@ export default function TerritoryList ({ ssrData, query, variables, destructureD
|
|||||||
{sub.name}
|
{sub.name}
|
||||||
</Link>
|
</Link>
|
||||||
<Info className='d-flex'><TerritoryInfo sub={sub} /></Info>
|
<Info className='d-flex'><TerritoryInfo sub={sub} /></Info>
|
||||||
{me && subActionDropdown && (
|
|
||||||
<ActionDropdown>
|
|
||||||
<ToggleSubSubscriptionDropdownItem sub={sub} />
|
|
||||||
<MuteSubDropdownItem sub={sub} />
|
|
||||||
</ActionDropdown>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.other}>
|
<div className={styles.other}>
|
||||||
{statComps.map((Comp, i) => <Comp key={i} sub={sub} />)}
|
{statComps.map((Comp, i) => <Comp key={i} sub={sub} />)}
|
||||||
|
|||||||
@ -133,12 +133,12 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child
|
|||||||
if (outlawed) {
|
if (outlawed) {
|
||||||
return href
|
return href
|
||||||
}
|
}
|
||||||
const isHashLink = href.startsWith('#')
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
return <Link id={props.id} target={isHashLink ? undefined : '_blank'} rel={rel} href={href}>{children}</Link>
|
return <Link id={props.id} target='_blank' rel={rel} href={href}>{children}</Link>
|
||||||
},
|
},
|
||||||
img: TextMediaOrLink,
|
img: TextMediaOrLink,
|
||||||
embed: (props) => <Embed {...props} topLevel={topLevel} />
|
embed: Embed
|
||||||
}), [outlawed, rel, TextMediaOrLink, topLevel])
|
}), [outlawed, rel, TextMediaOrLink, topLevel])
|
||||||
|
|
||||||
const carousel = useCarousel()
|
const carousel = useCarousel()
|
||||||
|
|||||||
@ -252,18 +252,11 @@
|
|||||||
margin-top: .25rem;
|
margin-top: .25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text li > :is(.twitterContainer, .nostrContainer, .wavlakeWrapper, .spotifyWrapper, .onlyImages) {
|
|
||||||
display: inline-flex;
|
|
||||||
vertical-align: top;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text ul,
|
.text ul,
|
||||||
.text ol {
|
.text ol {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 0rem;
|
margin-bottom: 0rem;
|
||||||
padding-left: 2rem;
|
padding-left: 2rem;
|
||||||
max-width: calc(100% - 1rem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text ol ol,
|
.text ol ol,
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import * as cookie from 'cookie'
|
|
||||||
import { cookieOptions } from '@/lib/auth'
|
|
||||||
|
|
||||||
export default function useCookie (name) {
|
|
||||||
const [value, setValue] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkCookie = () => {
|
|
||||||
const oldValue = value
|
|
||||||
const newValue = cookie.parse(document.cookie)[name]
|
|
||||||
if (oldValue !== newValue) setValue(newValue)
|
|
||||||
}
|
|
||||||
checkCookie()
|
|
||||||
// there's no way to listen for cookie changes that is supported by all browsers
|
|
||||||
// so we poll to detect changes
|
|
||||||
// see https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API
|
|
||||||
const interval = setInterval(checkCookie, 1000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [value])
|
|
||||||
|
|
||||||
const set = useCallback((value, options = {}) => {
|
|
||||||
document.cookie = cookie.serialize(name, value, { ...cookieOptions(), ...options })
|
|
||||||
setValue(value)
|
|
||||||
}, [name])
|
|
||||||
|
|
||||||
const remove = useCallback(() => {
|
|
||||||
document.cookie = value.serialize(name, '', { expires: 0, maxAge: 0 })
|
|
||||||
setValue(null)
|
|
||||||
}, [name])
|
|
||||||
|
|
||||||
return [value, set, remove]
|
|
||||||
}
|
|
||||||
@ -17,7 +17,7 @@ function itemToContent (item, { includeTitle = true } = {}) {
|
|||||||
content += `\n\n${item.text}`
|
content += `\n\n${item.text}`
|
||||||
}
|
}
|
||||||
|
|
||||||
content += `\n\nhttps://stacker.news/items/${item.id}`
|
content += `\n\noriginally posted at https://stacker.news/items/${item.id}`
|
||||||
|
|
||||||
return content.trim()
|
return content.trim()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
|
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
|
||||||
|
import { clearNotifications } from '@/lib/badge'
|
||||||
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
|
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import React, { useContext } from 'react'
|
import React, { useContext } from 'react'
|
||||||
import { clearNotifications } from '@/components/serviceworker'
|
|
||||||
|
|
||||||
export const HasNewNotesContext = React.createContext(false)
|
export const HasNewNotesContext = React.createContext(false)
|
||||||
|
|
||||||
|
|||||||
@ -1,180 +1,300 @@
|
|||||||
import { useMe } from '@/components/me'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useCallback, useMemo } from 'react'
|
|
||||||
|
|
||||||
const VERSION = 2
|
export function getDbName (userId, name) {
|
||||||
|
return `app:storage:${userId ?? ''}${name ? `:${name}` : ''}`
|
||||||
export function useIndexedDB (dbName) {
|
|
||||||
const { me } = useMe()
|
|
||||||
if (!dbName) dbName = me?.id ? `app:storage:${me.id}` : 'app:storage'
|
|
||||||
|
|
||||||
const set = useCallback(async (storeName, key, value) => {
|
|
||||||
const db = await _open(dbName, VERSION)
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await _set(db, storeName, key, value)
|
|
||||||
} finally {
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
}, [dbName])
|
|
||||||
|
|
||||||
const get = useCallback(async (storeName, key) => {
|
|
||||||
const db = await _open(dbName, VERSION)
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await _get(db, storeName, key)
|
|
||||||
} finally {
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
}, [dbName])
|
|
||||||
|
|
||||||
const deleteDb = useCallback(async () => {
|
|
||||||
return await _delete(dbName)
|
|
||||||
}, [dbName])
|
|
||||||
|
|
||||||
const open = useCallback(async () => {
|
|
||||||
return await _open(dbName, VERSION)
|
|
||||||
}, [dbName])
|
|
||||||
|
|
||||||
return useMemo(() => ({ set, get, deleteDb, open }), [set, get, deleteDb, open])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _open (dbName, version = 1) {
|
const DEFAULT_OPTIONS = { keyPath: 'id', autoIncrement: true }
|
||||||
return await new Promise((resolve, reject) => {
|
const DEFAULT_INDICES = []
|
||||||
if (typeof window.indexedDB === 'undefined') {
|
const DEFAULT_VERSION = 1
|
||||||
return reject(new IndexedDBOpenError('IndexedDB unavailable'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = window.indexedDB.open(dbName, version)
|
function useIndexedDB ({ dbName, storeName, options = DEFAULT_OPTIONS, indices = DEFAULT_INDICES, version = DEFAULT_VERSION }) {
|
||||||
|
const [db, setDb] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [notSupported, setNotSupported] = useState(false)
|
||||||
|
const operationQueue = useRef([])
|
||||||
|
|
||||||
|
const handleError = useCallback((error) => {
|
||||||
|
console.error('IndexedDB error:', error)
|
||||||
|
setError(error)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const processQueue = useCallback((db) => {
|
||||||
|
if (!db) return
|
||||||
|
|
||||||
request.onupgradeneeded = (event) => {
|
|
||||||
try {
|
try {
|
||||||
const db = event.target.result
|
// try to run a noop to see if the db is ready
|
||||||
if (!db.objectStoreNames.contains('vault')) db.createObjectStore('vault')
|
db.transaction(storeName)
|
||||||
if (db.objectStoreNames.contains('wallet_logs')) db.deleteObjectStore('wallet_logs')
|
while (operationQueue.current.length > 0) {
|
||||||
|
const operation = operationQueue.current.shift()
|
||||||
|
// if the db is the same as the one we're processing, run the operation
|
||||||
|
// else, we'll just clear the operation queue
|
||||||
|
// XXX this is a consquence of using a ref to store the queue and should be fixed
|
||||||
|
if (dbName === db.name) {
|
||||||
|
operation(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(new IndexedDBOpenError(`upgrade failed: ${error?.message}`))
|
handleError(error)
|
||||||
}
|
}
|
||||||
|
}, [dbName, storeName, handleError, operationQueue])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
let request
|
||||||
|
try {
|
||||||
|
if (!window.indexedDB) {
|
||||||
|
console.log('IndexedDB is not supported')
|
||||||
|
setNotSupported(true)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request = window.indexedDB.open(dbName, version)
|
||||||
|
|
||||||
request.onerror = (event) => {
|
request.onerror = (event) => {
|
||||||
reject(new IndexedDBOpenError(request.error?.message))
|
handleError(new Error('Error opening database'))
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onsuccess = (event) => {
|
request.onsuccess = (event) => {
|
||||||
const db = request.result
|
if (isMounted) {
|
||||||
resolve(db)
|
const database = event.target.result
|
||||||
|
database.onversionchange = () => {
|
||||||
|
database.close()
|
||||||
|
setDb(null)
|
||||||
|
handleError(new Error('Database is outdated, please reload the page'))
|
||||||
|
}
|
||||||
|
setDb(database)
|
||||||
|
processQueue(database)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _set (db, storeName, key, value) {
|
request.onupgradeneeded = (event) => {
|
||||||
return await new Promise((resolve, reject) => {
|
const database = event.target.result
|
||||||
let request
|
|
||||||
try {
|
try {
|
||||||
request = db
|
const store = database.createObjectStore(storeName, options)
|
||||||
.transaction(storeName, 'readwrite')
|
|
||||||
.objectStore(storeName)
|
indices.forEach(index => {
|
||||||
.put(value, key)
|
store.createIndex(index.name, index.keyPath, index.options)
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reject(new IndexedDBSetError(error?.message))
|
handleError(new Error('Error upgrading database: ' + error.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
reject(new IndexedDBSetError(event.target?.error?.message))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
resolve(request.result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _get (db, storeName, key) {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
let request
|
|
||||||
try {
|
|
||||||
request = db
|
|
||||||
.transaction(storeName)
|
|
||||||
.objectStore(storeName)
|
|
||||||
.get(key)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reject(new IndexedDBGetError(error?.message))
|
handleError(new Error('Error opening database: ' + error.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onerror = (event) => {
|
return () => {
|
||||||
reject(new IndexedDBGetError(event.target?.error?.message))
|
isMounted = false
|
||||||
|
if (db) {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [dbName, storeName, version, indices, options, handleError, processQueue])
|
||||||
|
|
||||||
|
const queueOperation = useCallback((operation) => {
|
||||||
|
if (notSupported) {
|
||||||
|
return Promise.reject(new Error('IndexedDB is not supported'))
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return Promise.reject(new Error('Database error: ' + error.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onsuccess = () => {
|
return new Promise((resolve, reject) => {
|
||||||
resolve(request.result)
|
const wrappedOperation = (db) => {
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _delete (dbName) {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
if (typeof window.indexedDB === 'undefined') {
|
|
||||||
return reject(new IndexedDBOpenError('IndexedDB unavailable'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = window.indexedDB.deleteDatabase(dbName)
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
reject(new IndexedDBDeleteError(event.target?.error?.message))
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
resolve(request.result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestPersistentStorage () {
|
|
||||||
try {
|
try {
|
||||||
if (!('persisted' in navigator.storage) || !('persist' in navigator.storage)) {
|
const result = operation(db)
|
||||||
throw new Error('persistent storage not supported')
|
resolve(result)
|
||||||
|
} catch (error) {
|
||||||
|
reject(error)
|
||||||
}
|
}
|
||||||
const persisted = await navigator.storage.persisted()
|
|
||||||
if (!persisted) {
|
|
||||||
// browser might prompt the user to allow persistent storage
|
|
||||||
return await navigator.storage.persist()
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('failed to request persistent storage:', err)
|
operationQueue.current.push(wrappedOperation)
|
||||||
|
processQueue(db)
|
||||||
|
})
|
||||||
|
}, [processQueue, db, notSupported, error])
|
||||||
|
|
||||||
|
const add = useCallback((value) => {
|
||||||
|
return queueOperation((db) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(storeName, 'readwrite')
|
||||||
|
const store = transaction.objectStore(storeName)
|
||||||
|
const request = store.add(value)
|
||||||
|
|
||||||
|
request.onerror = () => reject(new Error('Error adding data'))
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [queueOperation, storeName])
|
||||||
|
|
||||||
|
const get = useCallback((key) => {
|
||||||
|
return queueOperation((db) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(storeName, 'readonly')
|
||||||
|
const store = transaction.objectStore(storeName)
|
||||||
|
const request = store.get(key)
|
||||||
|
|
||||||
|
request.onerror = () => reject(new Error('Error getting data'))
|
||||||
|
request.onsuccess = () => resolve(request.result ? request.result : undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [queueOperation, storeName])
|
||||||
|
|
||||||
|
const getAll = useCallback(() => {
|
||||||
|
return queueOperation((db) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(storeName, 'readonly')
|
||||||
|
const store = transaction.objectStore(storeName)
|
||||||
|
const request = store.getAll()
|
||||||
|
|
||||||
|
request.onerror = () => reject(new Error('Error getting all data'))
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [queueOperation, storeName])
|
||||||
|
|
||||||
|
const set = useCallback((key, value) => {
|
||||||
|
return queueOperation((db) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(storeName, 'readwrite')
|
||||||
|
const store = transaction.objectStore(storeName)
|
||||||
|
const request = store.put(value, key)
|
||||||
|
|
||||||
|
request.onerror = () => reject(new Error('Error setting data'))
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [queueOperation, storeName])
|
||||||
|
|
||||||
|
const remove = useCallback((key) => {
|
||||||
|
return queueOperation((db) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(storeName, 'readwrite')
|
||||||
|
const store = transaction.objectStore(storeName)
|
||||||
|
const request = store.delete(key)
|
||||||
|
|
||||||
|
request.onerror = () => reject(new Error('Error removing data'))
|
||||||
|
request.onsuccess = () => resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [queueOperation, storeName])
|
||||||
|
|
||||||
|
const clear = useCallback((indexName = null, query = null) => {
|
||||||
|
return queueOperation((db) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(storeName, 'readwrite')
|
||||||
|
const store = transaction.objectStore(storeName)
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
// Clear all data if no query is provided
|
||||||
|
const request = store.clear()
|
||||||
|
request.onerror = () => reject(new Error('Error clearing all data'))
|
||||||
|
request.onsuccess = () => resolve()
|
||||||
|
} else {
|
||||||
|
// Clear data based on the query
|
||||||
|
const index = indexName ? store.index(indexName) : store
|
||||||
|
const request = index.openCursor(query)
|
||||||
|
let deletedCount = 0
|
||||||
|
|
||||||
|
request.onerror = () => reject(new Error('Error clearing data based on query'))
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = event.target.result
|
||||||
|
if (cursor) {
|
||||||
|
const deleteRequest = cursor.delete()
|
||||||
|
deleteRequest.onerror = () => reject(new Error('Error deleting item'))
|
||||||
|
deleteRequest.onsuccess = () => {
|
||||||
|
deletedCount++
|
||||||
|
cursor.continue()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
resolve(deletedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [queueOperation, storeName])
|
||||||
|
|
||||||
|
const getByIndex = useCallback((indexName, key) => {
|
||||||
|
return queueOperation((db) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(storeName, 'readonly')
|
||||||
|
const store = transaction.objectStore(storeName)
|
||||||
|
const index = store.index(indexName)
|
||||||
|
const request = index.get(key)
|
||||||
|
|
||||||
|
request.onerror = () => reject(new Error('Error getting data by index'))
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [queueOperation, storeName])
|
||||||
|
|
||||||
|
const getAllByIndex = useCallback((indexName, query, direction = 'next', limit = Infinity) => {
|
||||||
|
return queueOperation((db) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(storeName, 'readonly')
|
||||||
|
const store = transaction.objectStore(storeName)
|
||||||
|
const index = store.index(indexName)
|
||||||
|
const request = index.openCursor(query, direction)
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
request.onerror = () => reject(new Error('Error getting data by index'))
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = event.target.result
|
||||||
|
if (cursor && results.length < limit) {
|
||||||
|
results.push(cursor.value)
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
resolve(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [queueOperation, storeName])
|
||||||
|
|
||||||
|
const getPage = useCallback((page = 1, pageSize = 10, indexName = null, query = null, direction = 'next') => {
|
||||||
|
return queueOperation((db) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(storeName, 'readonly')
|
||||||
|
const store = transaction.objectStore(storeName)
|
||||||
|
const target = indexName ? store.index(indexName) : store
|
||||||
|
const request = target.openCursor(query, direction)
|
||||||
|
const results = []
|
||||||
|
let skipped = 0
|
||||||
|
let hasMore = false
|
||||||
|
|
||||||
|
request.onerror = () => reject(new Error('Error getting page'))
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = event.target.result
|
||||||
|
if (cursor) {
|
||||||
|
if (skipped < (page - 1) * pageSize) {
|
||||||
|
skipped++
|
||||||
|
cursor.continue()
|
||||||
|
} else if (results.length < pageSize) {
|
||||||
|
results.push(cursor.value)
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
hasMore = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasMore || !cursor) {
|
||||||
|
const countRequest = target.count()
|
||||||
|
countRequest.onsuccess = () => {
|
||||||
|
resolve({
|
||||||
|
data: results,
|
||||||
|
total: countRequest.result,
|
||||||
|
hasMore
|
||||||
|
})
|
||||||
|
}
|
||||||
|
countRequest.onerror = () => reject(new Error('Error counting items'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [queueOperation, storeName])
|
||||||
|
|
||||||
|
return { add, get, getAll, set, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported }
|
||||||
}
|
}
|
||||||
|
|
||||||
class IndexedDBError extends Error {
|
export default useIndexedDB
|
||||||
constructor (message) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'IndexedDBError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class IndexedDBOpenError extends IndexedDBError {
|
|
||||||
constructor (message) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'IndexedDBOpenError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class IndexedDBSetError extends IndexedDBError {
|
|
||||||
constructor (message) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'IndexedDBSetError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class IndexedDBGetError extends IndexedDBError {
|
|
||||||
constructor (message) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'IndexedDBGetError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class IndexedDBDeleteError extends IndexedDBError {
|
|
||||||
constructor (message) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'IndexedDBDeleteError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useApolloClient, useMutation } from '@apollo/client'
|
import { useApolloClient, useMutation } from '@apollo/client'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/client/errors'
|
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors'
|
||||||
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
|
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
|
||||||
import { INVOICE, CANCEL_INVOICE } from '@/fragments/invoice'
|
import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet'
|
||||||
|
|
||||||
export default function useInvoice () {
|
export default function useInvoice () {
|
||||||
const client = useApolloClient()
|
const client = useApolloClient()
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
|
|||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import { USER_ID } from '@/lib/constants'
|
import { USER_ID } from '@/lib/constants'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { useWalletRecvPrompt, WalletPromptClosed } from '@/wallets/client/hooks'
|
|
||||||
|
|
||||||
// this is intented to be compatible with upsert item mutations
|
// 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
|
// so that it can be reused for all post types and comments and we don't have
|
||||||
@ -23,17 +22,9 @@ export default function useItemSubmit (mutation,
|
|||||||
const crossposter = useCrossposter()
|
const crossposter = useCrossposter()
|
||||||
const [upsertItem] = usePaidMutation(mutation)
|
const [upsertItem] = usePaidMutation(mutation)
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
const walletPrompt = useWalletRecvPrompt()
|
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async ({ boost, crosspost, title, options, bounty, status, ...values }, { resetForm }) => {
|
async ({ boost, crosspost, title, options, bounty, status, ...values }, { resetForm }) => {
|
||||||
try {
|
|
||||||
await walletPrompt()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof WalletPromptClosed) return
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options) {
|
if (options) {
|
||||||
// remove existing poll options since else they will be appended as duplicates
|
// 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)
|
options = options.slice(item?.poll?.options?.length || 0).filter(o => o.trim().length > 0)
|
||||||
@ -102,7 +93,7 @@ export default function useItemSubmit (mutation,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [me, upsertItem, router, crossposter, item, sub, onSuccessfulSubmit,
|
}, [me, upsertItem, router, crossposter, item, sub, onSuccessfulSubmit,
|
||||||
navigateOnSubmit, extraValues, paidMutationOptions, walletPrompt]
|
navigateOnSubmit, extraValues, paidMutationOptions]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,124 +0,0 @@
|
|||||||
import preserveScroll from './preserve-scroll'
|
|
||||||
import { GET_NEW_COMMENTS } from '../fragments/comments'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants'
|
|
||||||
import { useQuery, useApolloClient } from '@apollo/client'
|
|
||||||
import { commentsViewedAfterComment } from '../lib/new-comments'
|
|
||||||
import {
|
|
||||||
updateItemQuery,
|
|
||||||
updateCommentFragment,
|
|
||||||
getLatestCommentCreatedAt,
|
|
||||||
updateAncestorsCommentCount,
|
|
||||||
calculateDepth
|
|
||||||
} from '../lib/comments'
|
|
||||||
|
|
||||||
const POLL_INTERVAL = 1000 * 5 // 5 seconds
|
|
||||||
|
|
||||||
// prepares and creates a fragment for injection into the cache
|
|
||||||
// also handles side effects like updating comment counts and viewedAt timestamps
|
|
||||||
function prepareComments (item, cache, newComment) {
|
|
||||||
const existingComments = item.comments?.comments || []
|
|
||||||
|
|
||||||
// is the incoming new comment already in item's existing comments?
|
|
||||||
// if so, we don't need to update the cache
|
|
||||||
if (existingComments.some(comment => comment.id === newComment.id)) return item
|
|
||||||
|
|
||||||
// count the new comment (+1) and its children (+ncomments)
|
|
||||||
const totalNComments = newComment.ncomments + 1
|
|
||||||
|
|
||||||
const itemHierarchy = item.path.split('.')
|
|
||||||
// update all ancestors comment count, but not the item itself
|
|
||||||
const ancestors = itemHierarchy.slice(0, -1)
|
|
||||||
updateAncestorsCommentCount(cache, ancestors, totalNComments)
|
|
||||||
// update commentsViewedAt to now, and add the number of new comments
|
|
||||||
const rootId = itemHierarchy[0]
|
|
||||||
commentsViewedAfterComment(rootId, Date.now(), totalNComments)
|
|
||||||
|
|
||||||
// add a flag to the new comment to indicate it was injected
|
|
||||||
const injectedComment = { ...newComment, injected: true }
|
|
||||||
|
|
||||||
// an item can either have a comments.comments field, or not
|
|
||||||
const payload = item.comments
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
ncomments: item.ncomments + totalNComments,
|
|
||||||
comments: {
|
|
||||||
...item.comments,
|
|
||||||
comments: [injectedComment, ...item.comments.comments]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// when the fragment doesn't have a comments field, we just update stats fields
|
|
||||||
: {
|
|
||||||
...item,
|
|
||||||
ncomments: item.ncomments + totalNComments
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheNewComments (cache, rootId, newComments, sort) {
|
|
||||||
for (const newComment of newComments) {
|
|
||||||
const { parentId } = newComment
|
|
||||||
const topLevel = Number(parentId) === Number(rootId)
|
|
||||||
|
|
||||||
// if the comment is a top level comment, update the item, else update the parent comment
|
|
||||||
if (topLevel) {
|
|
||||||
updateItemQuery(cache, rootId, sort, (item) => prepareComments(item, cache, newComment))
|
|
||||||
} else {
|
|
||||||
// if the comment is too deep, we can skip it
|
|
||||||
const depth = calculateDepth(newComment.path, rootId, parentId)
|
|
||||||
if (depth > COMMENT_DEPTH_LIMIT) continue
|
|
||||||
// inject the new comment into the parent comment's comments field
|
|
||||||
updateCommentFragment(cache, parentId, (parent) => prepareComments(parent, cache, newComment))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// useLiveComments fetches new comments under an item (rootId),
|
|
||||||
// that are newer than the latest comment createdAt (after), and injects them into the cache.
|
|
||||||
export default function useLiveComments (rootId, after, sort) {
|
|
||||||
const latestKey = `liveCommentsLatest:${rootId}`
|
|
||||||
const { cache } = useApolloClient()
|
|
||||||
const [latest, setLatest] = useState(after)
|
|
||||||
const [initialized, setInitialized] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const storedLatest = window.sessionStorage.getItem(latestKey)
|
|
||||||
if (storedLatest && storedLatest > after) {
|
|
||||||
setLatest(storedLatest)
|
|
||||||
} else {
|
|
||||||
setLatest(after)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apollo might update the cache before the page has fully rendered, causing reads of stale cached data
|
|
||||||
// this prevents GET_NEW_COMMENTS from producing results before the page has fully rendered
|
|
||||||
setInitialized(true)
|
|
||||||
}, [after])
|
|
||||||
|
|
||||||
const { data } = useQuery(GET_NEW_COMMENTS, SSR || !initialized
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
pollInterval: POLL_INTERVAL,
|
|
||||||
// only get comments newer than the passed latest timestamp
|
|
||||||
variables: { rootId, after: latest },
|
|
||||||
nextFetchPolicy: 'cache-and-network'
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data?.newComments?.comments?.length) return
|
|
||||||
|
|
||||||
// directly inject new comments into the cache, preserving scroll position
|
|
||||||
// quirk: scroll is preserved even if we are not injecting new comments due to dedupe
|
|
||||||
preserveScroll(() => cacheNewComments(cache, rootId, data.newComments.comments, sort))
|
|
||||||
|
|
||||||
// update latest timestamp to the latest comment created at
|
|
||||||
// save it to session storage, to persist between client-side navigations
|
|
||||||
const newLatest = getLatestCommentCreatedAt(data.newComments.comments, latest)
|
|
||||||
setLatest(newLatest)
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.sessionStorage.setItem(latestKey, newLatest)
|
|
||||||
}
|
|
||||||
}, [data, cache, rootId, sort, latest])
|
|
||||||
}
|
|
||||||
@ -2,9 +2,9 @@ import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import useQrPayment from '@/components/use-qr-payment'
|
import useQrPayment from '@/components/use-qr-payment'
|
||||||
import useInvoice from '@/components/use-invoice'
|
import useInvoice from '@/components/use-invoice'
|
||||||
import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/client/errors'
|
import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/errors'
|
||||||
import { GET_PAID_ACTION } from '@/fragments/paidAction'
|
import { GET_PAID_ACTION } from '@/fragments/paidAction'
|
||||||
import { useWalletPayment } from '@/wallets/client/hooks'
|
import { useWalletPayment } from '@/wallets/payment'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
this is just like useMutation with a few changes:
|
this is just like useMutation with a few changes:
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import Invoice from '@/components/invoice'
|
import Invoice from '@/components/invoice'
|
||||||
import { AnonWalletError, InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/client/errors'
|
import { InvoiceCanceledError, InvoiceExpiredError, AnonWalletError } from '@/wallets/errors'
|
||||||
import { useShowModal } from '@/components/modal'
|
import { useShowModal } from '@/components/modal'
|
||||||
import useInvoice from '@/components/use-invoice'
|
import useInvoice from '@/components/use-invoice'
|
||||||
import { sendPayment as weblnSendPayment } from '@/wallets/client/protocols/webln'
|
import { sendPayment } from '@/wallets/webln/client'
|
||||||
|
|
||||||
export default function useQrPayment () {
|
export default function useQrPayment () {
|
||||||
const invoice = useInvoice()
|
const invoice = useInvoice()
|
||||||
@ -19,7 +19,7 @@ export default function useQrPayment () {
|
|||||||
) => {
|
) => {
|
||||||
// if anon user and webln is available, try to pay with webln
|
// if anon user and webln is available, try to pay with webln
|
||||||
if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) {
|
if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) {
|
||||||
weblnSendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) })
|
sendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) })
|
||||||
}
|
}
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
let paid
|
let paid
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
// observe the passed element ref and return its visibility
|
|
||||||
export default function useVisibility (elementRef, options = {}) {
|
|
||||||
// threshold is the percentage of the element that must be visible to be considered visible
|
|
||||||
// with pastElement, we consider the element not visible only when we're past it
|
|
||||||
const { threshold = 0, pastElement = false } = options
|
|
||||||
const [isVisible, setIsVisible] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const element = elementRef.current
|
|
||||||
if (!element || !window.IntersectionObserver || typeof window === 'undefined') return
|
|
||||||
|
|
||||||
const observer = new window.IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setIsVisible(true)
|
|
||||||
} else if (pastElement) {
|
|
||||||
setIsVisible(entry.boundingClientRect.top > 0)
|
|
||||||
} else {
|
|
||||||
setIsVisible(false)
|
|
||||||
}
|
|
||||||
}, { threshold }
|
|
||||||
)
|
|
||||||
|
|
||||||
// observe the passed element ref
|
|
||||||
observer.observe(element)
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [threshold, elementRef, pastElement])
|
|
||||||
|
|
||||||
return isVisible
|
|
||||||
}
|
|
||||||
@ -8,7 +8,6 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Form, Input, SubmitButton } from './form'
|
import { Form, Input, SubmitButton } from './form'
|
||||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||||
import styles from './user-header.module.css'
|
import styles from './user-header.module.css'
|
||||||
import navStyles from '@/styles/nav.module.css'
|
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { NAME_MUTATION } from '@/fragments/users'
|
import { NAME_MUTATION } from '@/fragments/users'
|
||||||
import { QRCodeSVG } from 'qrcode.react'
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
@ -29,11 +28,9 @@ import { hexToBech32 } from '@/lib/nostr'
|
|||||||
import NostrIcon from '@/svgs/nostr.svg'
|
import NostrIcon from '@/svgs/nostr.svg'
|
||||||
import GithubIcon from '@/svgs/github-fill.svg'
|
import GithubIcon from '@/svgs/github-fill.svg'
|
||||||
import TwitterIcon from '@/svgs/twitter-fill.svg'
|
import TwitterIcon from '@/svgs/twitter-fill.svg'
|
||||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
import { UNKNOWN_LINK_REL, MEDIA_URL } from '@/lib/constants'
|
||||||
import ItemPopover from './item-popover'
|
import ItemPopover from './item-popover'
|
||||||
|
|
||||||
const MEDIA_URL = process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}`
|
|
||||||
|
|
||||||
export default function UserHeader ({ user }) {
|
export default function UserHeader ({ user }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -45,7 +42,7 @@ export default function UserHeader ({ user }) {
|
|||||||
<>
|
<>
|
||||||
<HeaderHeader user={user} />
|
<HeaderHeader user={user} />
|
||||||
<Nav
|
<Nav
|
||||||
className={navStyles.nav}
|
className={styles.nav}
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
>
|
>
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
|
|||||||
@ -3,6 +3,24 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
margin: 1rem 0;
|
||||||
|
justify-content: start;
|
||||||
|
font-size: 110%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav :global .nav-link {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav :global .nav-item:not(:first-child) {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav :global .active {
|
||||||
|
border-bottom: 2px solid var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.userimg {
|
.userimg {
|
||||||
clip-path: polygon(0% 0%, 83% 0%, 100% 100%, 17% 100%);
|
clip-path: polygon(0% 0%, 83% 0%, 100% 100%, 17% 100%);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user