Compare commits
287 Commits
eaa15b3b43
...
71ce403b0c
Author | SHA1 | Date | |
---|---|---|---|
|
71ce403b0c | ||
|
aef2cfc199 | ||
|
d9d968f0fa | ||
|
21a9696ea0 | ||
|
abfe54125a | ||
|
438b5041f1 | ||
|
3c637e5ec2 | ||
|
1379c419df | ||
|
dea8945e43 | ||
|
d5a2573657 | ||
|
0e842e9915 | ||
|
1bda8a6de2 | ||
|
7a7ed1745c | ||
|
44992fd1bf | ||
|
e0ddba09a8 | ||
|
0781156305 | ||
|
b96b5d0c89 | ||
|
cbc41c0d99 | ||
|
39bbaf2942 | ||
|
7a499f59a8 | ||
|
77e3f6aed1 | ||
|
8384f866b4 | ||
|
6fa1c226ae | ||
|
7c6a65c332 | ||
|
21532509fb | ||
|
067d9069cb | ||
|
1bcc864ef4 | ||
|
f58b853e8b | ||
|
6d244a5de6 | ||
|
45acbaa4fa | ||
|
bc569be34a | ||
|
416b675a2f | ||
|
7247083b72 | ||
|
d392bbe3b5 | ||
|
0299bbe4bc | ||
|
c77d10dad2 | ||
|
da9287f715 | ||
|
7857601c36 | ||
|
1aeb206842 | ||
|
d175d0e64d | ||
|
6aeffa7aff | ||
|
5f7d0ead1d | ||
|
2fa2f0baea | ||
|
1dc4018a3c | ||
|
0968c77bdf | ||
|
454cb55f7f | ||
|
9c8071339f | ||
|
20147cae15 | ||
|
cf5ac8272d | ||
|
a827dc6fde | ||
|
a4a0fdb060 | ||
|
ef1c586231 | ||
|
ec5ea1bc5f | ||
|
be1b497dfd | ||
|
f14c0ed0e4 | ||
|
09f8f12314 | ||
|
0155946d74 | ||
|
9e2c35c641 | ||
|
160b04ceaa | ||
|
ecac519efb | ||
|
7c10ded8a6 | ||
|
84ed0be86d | ||
|
6a3155fa93 | ||
|
6cc87ceac4 | ||
|
9092d90797 | ||
|
3f74279f29 | ||
|
243b094fcd | ||
|
2913e9a9b5 | ||
|
e1162b815a | ||
|
2a0dfd7af6 | ||
|
452fcb3659 | ||
|
f63d40196d | ||
|
276bb94eb9 | ||
|
8344866fca | ||
|
efefdeb0f0 | ||
|
eb55e6ac6c | ||
|
faa26ec68f | ||
|
6b440cfdf3 | ||
|
74ef0076fa | ||
|
ba370eeda6 | ||
|
d4efacadc0 | ||
|
96f149fa79 | ||
|
56a185d477 | ||
|
d5c9ffbddf | ||
|
075934e20b | ||
|
f7be43d3af | ||
|
5502d29d7e | ||
|
2ee685d5a9 | ||
|
45d7eaf1bb | ||
|
d670b38d1d | ||
|
0e71a85cd6 | ||
|
980f6da613 | ||
|
d89a4a429a | ||
|
df299a226c | ||
|
2caa189957 | ||
|
90fc1a9752 | ||
|
b1a0abe32c | ||
|
bfced699ea | ||
|
17aada6dbc | ||
|
c81043efa8 | ||
|
ec902ebc55 | ||
|
8382dda231 | ||
|
18a38d8363 | ||
|
3a27057781 | ||
|
06df4b7a8c | ||
|
a634b05bee | ||
|
7b3625eeeb | ||
|
1eea0a3ae0 | ||
|
96fd271573 | ||
|
de01d9493f | ||
|
8337aad596 | ||
|
0436bf68eb | ||
|
a419eefe9b | ||
|
462016042c | ||
|
a5de2dae01 | ||
|
4034755410 | ||
|
04daf87f40 | ||
|
54ccd19a75 | ||
|
3c35e905d4 | ||
|
6efc0be9e5 | ||
|
acf042f06e | ||
|
089fe4d57b | ||
|
3a3f28beef | ||
|
27671ae746 | ||
|
37d7ed59bb | ||
|
652315e9a0 | ||
|
6d50f7c9fc | ||
|
a7f73fef90 | ||
|
36045b8ac9 | ||
|
67b30c6974 | ||
|
dacd37aeef | ||
|
16da50733c | ||
|
830967467a | ||
|
524b1b97f3 | ||
|
aebba27c57 | ||
|
12ff0911cb | ||
|
dc01ebdb26 | ||
|
874694eb10 | ||
|
0310611a2d | ||
|
4d8743bf13 | ||
|
79d3eb492d | ||
|
590d73ece0 | ||
|
59d4fadb50 | ||
|
2bcaea2f58 | ||
|
df082f424f | ||
|
bf62149681 | ||
|
f7d59196fc | ||
|
0002c0a0f6 | ||
|
f5dff4b0bb | ||
|
1888b19792 | ||
|
d13ba034fe | ||
|
f3df1092d8 | ||
|
fa177317e3 | ||
|
6a29dea232 | ||
|
8c1b8d3118 | ||
|
00f0e971c9 | ||
|
97cf36d2f6 | ||
|
bf777f72c4 | ||
|
5d4f88c3bb | ||
|
407c0a9b49 | ||
|
d9213c39e7 | ||
|
8ba572d5f1 | ||
|
d418f01fa9 | ||
|
d11c60fc80 | ||
|
ef37fff0f8 | ||
|
30911f3039 | ||
|
f12c03198d | ||
|
d7ddfffbf0 | ||
|
d2c71ca08f | ||
|
6d9d20a8f0 | ||
|
05e7f0dded | ||
|
51ec00d549 | ||
|
92f4b93099 | ||
|
6d04b40adc | ||
|
3e75671c4b | ||
|
64a9f5e50d | ||
|
0edf68cab9 | ||
|
d4e3853f27 | ||
|
586cb86ec2 | ||
|
90c6d5a336 | ||
|
faf11138c4 | ||
|
f01a5fde00 | ||
|
a1bc6dc217 | ||
|
f754b530ff | ||
|
b864290cac | ||
|
9dbd9d87d4 | ||
|
dc196be807 | ||
|
236f930a17 | ||
|
97317d4c0c | ||
|
4ad64d658f | ||
|
e8d8e64bfb | ||
|
32d5f8277a | ||
|
c72dbb0c01 | ||
|
addd1263ab | ||
|
ac26fdfb14 | ||
|
eebc791683 | ||
|
1147e1fb81 | ||
|
e85f40e9ba | ||
|
9fc819ec37 | ||
|
838418ab81 | ||
|
5965b3d090 | ||
|
ab87ad5b11 | ||
|
ea8a28a23e | ||
|
4c2f059fb5 | ||
|
ff057039f5 | ||
|
e94a231192 | ||
|
a1a8b286e7 | ||
|
24c90ec6c3 | ||
|
9445fe6e36 | ||
|
7ce3967200 | ||
|
6ac60604cc | ||
|
7bde3fe55b | ||
|
66dbf2496e | ||
|
37eb0e905c | ||
|
984790ed5c | ||
|
9b77cf096d | ||
|
b6f6cc821c | ||
|
c33b62abb4 | ||
|
22e4f8acf5 | ||
|
719cb2d507 | ||
|
d3b81e4346 | ||
|
1103f04f4b | ||
|
fd7ffb90f5 | ||
|
e401c6f277 | ||
|
7be94dcfed | ||
|
66d7eef617 | ||
|
bc0be4f92a | ||
|
34c02ece88 | ||
|
9b73990083 | ||
|
a3e0b6aa9c | ||
|
a7245930c2 | ||
|
52365c32ed | ||
|
9df5a52bd3 | ||
|
5f2c8bf380 | ||
|
6e23709de1 | ||
|
af096a08a0 | ||
|
1835a8f255 | ||
|
4e631f0373 | ||
|
f635514c32 | ||
|
e4a2228d7c | ||
|
f43a522a87 | ||
|
40cd0ea422 | ||
|
78f7e006d5 | ||
|
b617ac0a56 | ||
|
06d05c7f37 | ||
|
1f5e0833db | ||
|
2cf0f1d268 | ||
|
644899469f | ||
|
d7a7273ca4 | ||
|
78c5552e5b | ||
|
75a8828eeb | ||
|
71e06f09e3 | ||
|
93608019cd | ||
|
0f1818c9b9 | ||
|
88253e5478 | ||
|
2d51a5def9 | ||
|
e0bfc590b2 | ||
|
5700a4090a | ||
|
14fadbaed6 | ||
|
bcc92e54fd | ||
|
73e0b5055e | ||
|
fca4d1ff92 | ||
|
3568fc1c62 | ||
|
8d4f99ea8d | ||
|
39d0c55124 | ||
|
a2faa31d49 | ||
|
39dbc891b0 | ||
|
fb8c95c0ba | ||
|
ec7b05830a | ||
|
71fdd873c5 | ||
|
2136c1a11f | ||
|
26a23ade92 | ||
|
de7f96a3a4 | ||
|
e29c6b4842 | ||
|
bb76c6a138 | ||
|
5701bf640a | ||
|
ef8c738582 | ||
|
8a6b825659 | ||
|
1921c253ba | ||
|
c93f658ade | ||
|
30ca04c6fe | ||
|
895efd0181 | ||
|
501bf1609b | ||
|
3878802c03 | ||
|
df17bc3b25 | ||
|
04a4092090 | ||
|
d7e01d0186 |
4
.cursor/BUGBOT.md
Normal file
4
.cursor/BUGBOT.md
Normal file
@ -0,0 +1,4 @@
|
||||
# Project review guidelines
|
||||
|
||||
- ignore ??? as a placeholder in awards.csv
|
||||
|
@ -32,6 +32,12 @@ SLACK_CHANNEL_ID=
|
||||
LNAUTH_URL=http://localhost:3000/api/lnauth
|
||||
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 #
|
||||
# which you can override in .env.local #
|
||||
@ -57,8 +63,8 @@ INVOICE_HMAC_KEY=a4c1d9c81edb87b79d28809876a18cf72293eadb39f92f3f4f2f1cfbdf907c9
|
||||
|
||||
# lnd
|
||||
# xxd -p -c0 docker/lnd/sn/regtest/admin.macaroon
|
||||
LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a43434165696741774942416749516139493834682b48653350385a437541525854554d54414b42676771686b6a4f50515144416a41344d5238770a485159445651514b45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d5255774577594456515144457778694e6a41785a5749780a4d474d354f444d774868634e4d6a51774d7a41334d5463774d6a45355768634e4d6a55774e5441794d5463774d6a4535576a41344d523877485159445651514b0a45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d5255774577594456515144457778694e6a41785a5749784d474d354f444d770a5754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e4341415365596a4b62542b4a4a4a37624b6770677a6d6c3278496130364e3174680a2f4f7033533173382b4f4a41387836647849682f326548556b4f7578675a36703549434b496f375a544c356a5963764375793941334b6e466f3448544d4948510a4d41344741315564447745422f775145417749437044415442674e56485355454444414b4267677242674546425163444154415042674e5648524d42416638450a425441444151482f4d4230474131556444675157424252545756796e653752786f747568717354727969466d6a36736c557a423542674e5648524545636a42770a676778694e6a41785a5749784d474d354f444f4343577876593246736147397a64494947633235666247356b6768526f62334e304c6d52765932746c636935700a626e526c636d356862494945645735706549494b64573570654842685932746c64494948596e566d59323975626f6345667741414159635141414141414141410a41414141414141414141414141596345724273414254414b42676771686b6a4f5051514441674e4941444246416945413873616c4a667134476671465557532f0a35347a335461746c6447736673796a4a383035425a5263334f326f434943794e6e3975716976566f5575365935345143624c3966394c575779547a516e61616e0a656977482f51696b0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a
|
||||
LND_MACAROON=0201036c6e6402f801030a106cf4e146abffa5d766befbbf4c73b5a31201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e6572617465120472656164000006202c3bfd55c191e925cbffd73712c9d4b9b4a8440410bde5f8a0a6e33af8b3d876
|
||||
LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a4343416569674177494241674951484f4a69597458736c72592f4931376933574c444354414b42676771686b6a4f50515144416a41344d5238770a485159445651514b45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d52557745775944565151444577777a4e54526d4d574e690a4f546b7a595451774868634e4d6a55774e54497a4d4467784d444d345768634e4d6a59774e7a45344d4467784d444d34576a41344d523877485159445651514b0a45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d52557745775944565151444577777a4e54526d4d574e694f546b7a595451770a5754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e434141524b6d733131422b4e58554e642f54574347492b4b2b5046686b485a31410a5449647732566e766a344f6130784c696c515a4d7779647149586c7a724641485064646a3566697934584c456f43364d4e427636585277706f3448544d4948510a4d41344741315564447745422f775145417749437044415442674e56485355454444414b4267677242674546425163444154415042674e5648524d42416638450a425441444151482f4d42304741315564446751574242526f433554634e58746366464f7458393171364364337a6930327a54423542674e5648524545636a42770a6767777a4e54526d4d574e694f546b7a5954534343577876593246736147397a64494947633235666247356b6768526f62334e304c6d52765932746c636935700a626e526c636d356862494945645735706549494b64573570654842685932746c64494948596e566d59323975626f6345667741414159635141414141414141410a4141414141414141414141414159634572424941427a414b42676771686b6a4f5051514441674e494144424641694541324941462b32436746704a754e5445750a34524f63322f70625870476f4934365573724a65525972614d33414349423974424c6759777a597a2b596b5a4e7a417a7077454c754935564f505959724a6f6b0a7270754d32316b690a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a
|
||||
LND_MACAROON=0201036c6e6402f801030a10ba643b9c3fe23f760e1ee63e0196656e1201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620fd0027075985f7073217aa9aaae4d14db0e7ca38f4e572c3b85c81cf6bb580b3
|
||||
LND_SOCKET=sn_lnd:10009
|
||||
|
||||
# nostr (NIP-57 zap receipts)
|
||||
@ -79,6 +85,7 @@ IMGPROXY_READ_TIMEOUT=10
|
||||
IMGPROXY_WRITE_TIMEOUT=10
|
||||
IMGPROXY_DOWNLOAD_TIMEOUT=9
|
||||
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
|
||||
IMGPROXY_ALLOW_ORIGIN=http://localhost:3000
|
||||
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
|
||||
# IMGPROXY_ENABLE_DEBUG_HEADERS=true
|
||||
|
||||
@ -133,8 +140,8 @@ SN_LND_REST_PORT=8080
|
||||
SN_LND_GRPC_PORT=10009
|
||||
SN_LND_P2P_PORT=9735
|
||||
# docker exec -u lnd sn_lnd lncli newaddress p2wkh --unused
|
||||
SN_LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl
|
||||
SN_LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
|
||||
SN_LND_PUBKEY=03dc0de8fbe29ef3d26554c615adfd17aaca959403c4e9ecebaac4b83978d86342
|
||||
SN_LND_ADDR=bcrt1qu6g49vrl8n4ay99hr04wefkfy2e8g0z4nc0sjw
|
||||
# sn_lndk stuff
|
||||
SN_LNDK_GRPC_PORT=10012
|
||||
|
||||
@ -177,10 +184,16 @@ grpc_proxy=http://tor:7050/
|
||||
|
||||
# lnbits
|
||||
LNBITS_WEB_PORT=5001
|
||||
LNBITS_WEB_PORT_V1=5002
|
||||
|
||||
# CPU shares for each category
|
||||
CPU_SHARES_IMPORTANT=1024
|
||||
CPU_SHARES_MODERATE=512
|
||||
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,8 +10,9 @@ _Was anything unclear during your work on this PR? Anything we should definitely
|
||||
|
||||
## Checklist
|
||||
|
||||
**Are your changes backwards compatible? Please answer below:**
|
||||
**Are your changes backward 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:**
|
||||
|
||||
|
29
.github/workflows/extend-awards.yml
vendored
29
.github/workflows/extend-awards.yml
vendored
@ -14,7 +14,10 @@ jobs:
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'closed' &&
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@ -22,14 +25,36 @@ jobs:
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- 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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
if: env.exists == 'false'
|
||||
with:
|
||||
add-paths: awards.csv
|
||||
branch: extend-awards/patch
|
||||
commit-message: Extending awards.csv
|
||||
title: Extending awards.csv
|
||||
body: A PR was merged that solves an issue and awards.csv should be extended.
|
||||
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.
|
||||
delete-branch: true
|
||||
|
11
.gitignore
vendored
11
.gitignore
vendored
@ -53,6 +53,7 @@ docker-compose.*.yml
|
||||
*.sql
|
||||
!/prisma/migrations/*/*.sql
|
||||
!/docker/db/seed.sql
|
||||
!/docker/db/wallet-seed.sql
|
||||
|
||||
# nostr wallet connect
|
||||
scripts/nwc-keys.json
|
||||
@ -65,4 +66,12 @@ docker/lnbits/data
|
||||
|
||||
# nostr link extract
|
||||
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,6 +87,9 @@ COMMANDS
|
||||
psql open psql on db
|
||||
prisma run prisma commands
|
||||
|
||||
domains:
|
||||
domains custom domains dev management
|
||||
|
||||
dev:
|
||||
pr fetch and checkout a pr
|
||||
lint run linters
|
||||
@ -131,7 +134,36 @@ services:
|
||||
|
||||
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>
|
||||
|
||||
@ -431,6 +463,25 @@ To enable Web Push locally, you will need to set the `VAPID_*` env vars. `VAPID_
|
||||
|
||||
<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
|
||||
|
||||
<br>
|
||||
@ -468,7 +519,7 @@ Open a [discussion](http://github.com/stackernews/stacker.news/discussions) or [
|
||||
|
||||
# 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) (EBAF 75DA 7279 CB48).
|
||||
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).
|
||||
|
||||
<br>
|
||||
|
||||
|
@ -103,7 +103,7 @@ stateDiagram-v2
|
||||
| donations | x | | x | x | x | | | x | |
|
||||
| update posts | x | | x | | x | | x | x | |
|
||||
| update comments | x | | x | | x | | x | x | |
|
||||
| receive | | x | | | x | x | x | | x |
|
||||
| receive | | | | | x | x | x | | x |
|
||||
| buy fee credits | | | 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).
|
||||
|
||||
### 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.
|
||||
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)).
|
||||
2. This applies to **ALL**, and I really mean **ALL**, read data regardless of how you read the data within the `read committed` transaction:
|
||||
- independent statements
|
||||
- `WITH` queries (CTEs) in the same statement
|
||||
|
@ -62,7 +62,7 @@ export async function onPaid ({ invoice, actId }, { tx }) {
|
||||
// denormalize downzaps
|
||||
await tx.$executeRaw`
|
||||
WITH territory AS (
|
||||
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
|
||||
SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName"
|
||||
FROM "Item" i
|
||||
LEFT JOIN "Item" r ON r.id = i."rootId"
|
||||
WHERE i.id = ${itemAct.itemId}::INTEGER
|
||||
|
@ -227,7 +227,7 @@ async function performP2PAction (actionType, args, incomingContext) {
|
||||
await assertBelowMaxPendingInvoices(incomingContext)
|
||||
|
||||
const description = await paidActions[actionType].describe(args, incomingContext)
|
||||
const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, {
|
||||
const { invoice, wrappedInvoice, protocol, maxFee } = await createWrappedInvoice(userId, {
|
||||
msats: cost,
|
||||
feePercent: sybilFeePercent,
|
||||
description,
|
||||
@ -239,7 +239,7 @@ async function performP2PAction (actionType, args, incomingContext) {
|
||||
invoiceArgs: {
|
||||
bolt11: invoice,
|
||||
wrappedBolt11: wrappedInvoice,
|
||||
wallet,
|
||||
protocol,
|
||||
maxFee
|
||||
}
|
||||
}
|
||||
@ -269,7 +269,7 @@ async function performDirectAction (actionType, args, incomingContext) {
|
||||
|
||||
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
|
||||
|
||||
for await (const { invoice, logger, wallet } of createUserInvoice(userId, {
|
||||
for await (const { invoice, logger, protocol } of createUserInvoice(userId, {
|
||||
msats: cost,
|
||||
description,
|
||||
expiry: INVOICE_EXPIRE_SECS
|
||||
@ -293,7 +293,7 @@ async function performDirectAction (actionType, args, incomingContext) {
|
||||
bolt11: invoice,
|
||||
msats: cost,
|
||||
hash,
|
||||
walletId: wallet.id,
|
||||
protocolId: protocol.id,
|
||||
receiverId: userId
|
||||
}
|
||||
}),
|
||||
@ -346,22 +346,26 @@ export async function retryPaidAction (actionType, args, incomingContext) {
|
||||
invoiceId: failedInvoice.id
|
||||
},
|
||||
include: {
|
||||
wallet: true
|
||||
protocol: {
|
||||
include: {
|
||||
wallet: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (invoiceForward) {
|
||||
// this is a wrapped invoice, we need to retry it with receiver fallbacks
|
||||
try {
|
||||
const { userId } = invoiceForward.wallet
|
||||
const { userId } = invoiceForward.protocol.wallet
|
||||
// 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, wallet, maxFee } = await createWrappedInvoice(userId, {
|
||||
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, protocol, maxFee } = await createWrappedInvoice(userId, {
|
||||
msats: failedInvoice.msatsRequested,
|
||||
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
|
||||
description: await action.describe?.(actionArgs, retryContext),
|
||||
expiry: INVOICE_EXPIRE_SECS
|
||||
}, retryContext)
|
||||
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
|
||||
invoiceArgs = { bolt11, wrappedBolt11, protocol, maxFee }
|
||||
} catch (err) {
|
||||
console.log('failed to retry wrapped invoice, falling back to SN:', err)
|
||||
}
|
||||
@ -429,7 +433,7 @@ async function createSNInvoice (actionType, args, context) {
|
||||
|
||||
async function createDbInvoice (actionType, args, context) {
|
||||
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context
|
||||
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs
|
||||
const { bolt11, wrappedBolt11, preimage, protocol, maxFee } = invoiceArgs
|
||||
|
||||
const db = tx ?? models
|
||||
|
||||
@ -468,9 +472,9 @@ async function createDbInvoice (actionType, args, context) {
|
||||
invoice: {
|
||||
create: invoiceData
|
||||
},
|
||||
wallet: {
|
||||
protocol: {
|
||||
connect: {
|
||||
id: wallet.id
|
||||
id: protocol.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||
import { ANON_ITEM_SPAM_INTERVAL, ANON_FEE_MULTIPLIER, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers, notifyThreadSubscribers } from '@/lib/webPush'
|
||||
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||
import { GqlInputError } from '@/lib/error'
|
||||
import { throwOnExpiredUploads } from '@/api/resolvers/upload'
|
||||
|
||||
export const anonable = true
|
||||
|
||||
@ -38,12 +39,12 @@ export async function getBaseCost ({ models, bio, parentId, subName }) {
|
||||
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
|
||||
const baseCost = await getBaseCost({ models, bio, parentId, subName })
|
||||
|
||||
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + upload fees + boost
|
||||
// cost = baseCost * 10^num_items_in_10m * 10 (ANON_FEE_MULTIPLIER constant) or 1 (user) + upload fees + boost
|
||||
const [{ cost }] = await models.$queryRaw`
|
||||
SELECT ${baseCost}::INTEGER
|
||||
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? USER_ID.anon}::INTEGER,
|
||||
${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
|
||||
* ${me ? 1 : 100}::INTEGER
|
||||
* ${me ? 1 : ANON_FEE_MULTIPLIER}::INTEGER
|
||||
+ (SELECT "nUnpaid" * "uploadFeesMsats"
|
||||
FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
|
||||
+ ${satsToMsats(boost)}::INTEGER as cost`
|
||||
@ -61,15 +62,7 @@ export async function perform (args, context) {
|
||||
const { tx, me, cost } = context
|
||||
const boostMsats = satsToMsats(boost)
|
||||
|
||||
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.`)
|
||||
}
|
||||
await throwOnExpiredUploads(uploadIds, { tx })
|
||||
|
||||
let invoiceData = {}
|
||||
if (invoiceId) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||
import { uploadFees } from '../resolvers/upload'
|
||||
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
|
||||
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
||||
import { notifyItemMention, notifyMention } from '@/lib/webPush'
|
||||
import { satsToMsats } from '@/lib/format'
|
||||
@ -17,7 +17,7 @@ export async function getCost ({ id, boost = 0, uploadIds, bio }, { me, models }
|
||||
// or more boost
|
||||
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
|
||||
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
|
||||
const cost = BigInt(totalFeesMsats) + satsToMsats(boost - old.boost)
|
||||
const cost = totalFeesMsats + satsToMsats(boost - old.boost)
|
||||
|
||||
if (cost > 0 && old.invoiceActionState && old.invoiceActionState !== 'PAID') {
|
||||
throw new Error('creation invoice not paid')
|
||||
@ -60,6 +60,7 @@ export async function perform (args, context) {
|
||||
const itemMentions = await getItemMentions(args, context)
|
||||
const itemUploads = uploadIds.map(id => ({ uploadId: id }))
|
||||
|
||||
await throwOnExpiredUploads(uploadIds, { tx })
|
||||
await tx.upload.updateMany({
|
||||
where: { id: { in: uploadIds } },
|
||||
data: { paid: true }
|
||||
@ -163,7 +164,8 @@ export async function nonCriticalSideEffects ({ invoice, id }, { models }) {
|
||||
where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) },
|
||||
include: {
|
||||
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
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
||||
import { toPositiveBigInt, numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
|
||||
import { PAID_ACTION_PAYMENT_METHODS, PROXY_RECEIVE_FEE_PERCENT } from '@/lib/constants'
|
||||
import { toPositiveBigInt, numWithUnits, msatsToSats } from '@/lib/format'
|
||||
import { notifyDeposit } from '@/lib/webPush'
|
||||
import { getInvoiceableWallets } from '@/wallets/server'
|
||||
|
||||
@ -16,22 +16,20 @@ export async function getCost ({ msats }) {
|
||||
|
||||
export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) {
|
||||
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null
|
||||
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && !me?.directReceive) return null
|
||||
|
||||
// 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 })
|
||||
if (wallets.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (cost < satsToMsats(me.receiveCreditsBelowSats)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return me.id
|
||||
}
|
||||
|
||||
export async function getSybilFeePercent () {
|
||||
return 10n
|
||||
return PROXY_RECEIVE_FEE_PERCENT
|
||||
}
|
||||
|
||||
export async function perform ({
|
||||
@ -58,24 +56,6 @@ export async function describe ({ description }, { me, cost, paymentMethod, sybi
|
||||
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 }) {
|
||||
await notifyDeposit(invoice.userId, invoice)
|
||||
await models.$executeRaw`
|
||||
|
@ -2,6 +2,7 @@ import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/consta
|
||||
import { satsToMsats } from '@/lib/format'
|
||||
import { nextBilling } from '@/lib/territory'
|
||||
import { initialTrust } from './lib/territory'
|
||||
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
|
||||
|
||||
export const anonable = false
|
||||
|
||||
@ -11,8 +12,9 @@ export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||
]
|
||||
|
||||
export async function getCost ({ billingType }) {
|
||||
return satsToMsats(TERRITORY_PERIOD_COST(billingType))
|
||||
export async function getCost ({ billingType, uploadIds }, { models, me }) {
|
||||
const { totalFees } = await uploadFees(uploadIds, { models, me })
|
||||
return satsToMsats(BigInt(TERRITORY_PERIOD_COST(billingType)) + totalFees)
|
||||
}
|
||||
|
||||
export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
|
||||
@ -21,6 +23,19 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
|
||||
const billedLastAt = new Date()
|
||||
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({
|
||||
data: {
|
||||
...data,
|
||||
|
@ -36,8 +36,15 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
|
||||
data.userId = me.id
|
||||
|
||||
if (sub.userId !== me.id) {
|
||||
await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } })
|
||||
await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } })
|
||||
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 } })
|
||||
// this will throw if the prior user has already unsubscribed
|
||||
await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
await tx.subAct.create({
|
||||
@ -78,9 +85,16 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
|
||||
}
|
||||
})
|
||||
|
||||
await tx.userSubTrust.createMany({
|
||||
data: initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
|
||||
})
|
||||
const trust = initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
|
||||
for (const t of trust) {
|
||||
await tx.userSubTrust.upsert({
|
||||
where: {
|
||||
userId_subName: { userId: t.userId, subName: t.subName }
|
||||
},
|
||||
update: t,
|
||||
create: t
|
||||
})
|
||||
}
|
||||
|
||||
return updatedSub
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/consta
|
||||
import { satsToMsats } from '@/lib/format'
|
||||
import { proratedBillingCost } from '@/lib/territory'
|
||||
import { datePivot } from '@/lib/time'
|
||||
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
|
||||
|
||||
export const anonable = false
|
||||
|
||||
@ -11,18 +12,16 @@ export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||
]
|
||||
|
||||
export async function getCost ({ oldName, billingType }, { models }) {
|
||||
export async function getCost ({ oldName, billingType, uploadIds }, { models, me }) {
|
||||
const oldSub = await models.sub.findUnique({
|
||||
where: {
|
||||
name: oldName
|
||||
}
|
||||
})
|
||||
|
||||
const cost = proratedBillingCost(oldSub, billingType)
|
||||
if (!cost) {
|
||||
return 0n
|
||||
}
|
||||
const { totalFees } = await uploadFees(uploadIds, { models, me })
|
||||
|
||||
const cost = BigInt(proratedBillingCost(oldSub, billingType)) + totalFees
|
||||
return satsToMsats(cost)
|
||||
}
|
||||
|
||||
@ -63,6 +62,19 @@ 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({
|
||||
data,
|
||||
where: {
|
||||
|
@ -39,11 +39,11 @@ export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models,
|
||||
return null
|
||||
}
|
||||
|
||||
const wallets = await getInvoiceableWallets(item.userId, { models })
|
||||
const protocols = await getInvoiceableWallets(item.userId, { models })
|
||||
|
||||
// request peer invoice if they have an attached wallet and have not forwarded the item
|
||||
// and the receiver doesn't want to receive credits
|
||||
if (wallets.length > 0 &&
|
||||
if (protocols.length > 0 &&
|
||||
item.itemForwards.length === 0 &&
|
||||
sats >= item.user.receiveCreditsBelowSats) {
|
||||
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
|
||||
await tx.$queryRaw`
|
||||
WITH territory AS (
|
||||
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
|
||||
SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName"
|
||||
FROM "Item" i
|
||||
LEFT JOIN "Item" r ON r.id = i."rootId"
|
||||
WHERE i.id = ${itemAct.itemId}::INTEGER
|
||||
|
@ -6,9 +6,9 @@ import { parsePaymentRequest, payViaPaymentRequest } from 'ln-service'
|
||||
// paying actions are completely distinct from paid actions
|
||||
// and there's only one paying action: send
|
||||
// ... still we want the api to at least be similar
|
||||
export default async function performPayingAction ({ bolt11, maxFee, walletId }, { me, models, lnd }) {
|
||||
export default async function performPayingAction ({ bolt11, maxFee, protocolId }, { me, models, lnd }) {
|
||||
try {
|
||||
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, walletId)
|
||||
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, protocolId)
|
||||
|
||||
if (!me) {
|
||||
throw new Error('You must be logged in to perform this action')
|
||||
@ -34,8 +34,8 @@ export default async function performPayingAction ({ bolt11, maxFee, walletId },
|
||||
msatsPaying: toPositiveBigInt(decoded.mtokens),
|
||||
msatsFeePaying: satsToMsats(maxFee),
|
||||
userId: me.id,
|
||||
walletId,
|
||||
autoWithdraw: !!walletId
|
||||
protocolId,
|
||||
autoWithdraw: !!protocolId
|
||||
}
|
||||
})
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
||||
|
@ -1,7 +1,8 @@
|
||||
import user from './user'
|
||||
import message from './message'
|
||||
import item from './item'
|
||||
import wallet from './wallet'
|
||||
import walletV1 from './wallet'
|
||||
import walletV2 from '@/wallets/server/resolvers'
|
||||
import lnurl from './lnurl'
|
||||
import notifications from './notifications'
|
||||
import invite from './invite'
|
||||
@ -19,7 +20,6 @@ import chainFee from './chainFee'
|
||||
import { GraphQLScalarType, Kind } from 'graphql'
|
||||
import { createIntScalar } from 'graphql-scalar'
|
||||
import paidAction from './paidAction'
|
||||
import vault from './vault'
|
||||
|
||||
const date = new GraphQLScalarType({
|
||||
name: 'Date',
|
||||
@ -54,6 +54,6 @@ const limit = createIntScalar({
|
||||
maximum: 1000
|
||||
})
|
||||
|
||||
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
||||
export default [user, item, message, walletV1, walletV2, lnurl, notifications, invite, sub,
|
||||
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
|
||||
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]
|
||||
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction]
|
||||
|
@ -15,7 +15,6 @@ import {
|
||||
FULL_COMMENTS_THRESHOLD
|
||||
} from '@/lib/constants'
|
||||
import { msatsToSats } from '@/lib/format'
|
||||
import { parse } from 'tldts'
|
||||
import uu from 'url-unshort'
|
||||
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, validateSchema } from '@/lib/validate'
|
||||
import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
|
||||
@ -26,11 +25,15 @@ import assertApiKeyNotPermitted from './apiKey'
|
||||
import performPaidAction from '../paidAction'
|
||||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||
import { verifyHmac } from './wallet'
|
||||
import { parse } from 'tldts'
|
||||
import { shuffleArray } from '@/lib/rand'
|
||||
|
||||
function commentsOrderByClause (me, models, sort) {
|
||||
const sharedSortsArray = []
|
||||
sharedSortsArray.push('("Item"."pinId" IS NOT 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(', ')
|
||||
|
||||
if (sort === 'recent') {
|
||||
@ -509,7 +512,7 @@ export default {
|
||||
${whereClause(
|
||||
'"parentId" IS NULL',
|
||||
'"Item"."deletedAt" IS NULL',
|
||||
'"Item"."status" = \'ACTIVE\'',
|
||||
activeOrMine(me),
|
||||
'created_at <= $1',
|
||||
'"pinId" IS NULL',
|
||||
subClause(sub, 4)
|
||||
@ -592,7 +595,13 @@ export default {
|
||||
const response = await fetch(ensureProtocol(url), { redirect: 'follow' })
|
||||
const html = await response.text()
|
||||
const doc = domino.createWindow(html).document
|
||||
const metadata = getMetadata(doc, url, { title: metadataRuleSets.title, publicationDate: publicationDateRuleSet })
|
||||
const titleRuleSet = {
|
||||
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 moreThanOneYearAgo = metadata.publicationDate && metadata.publicationDate < datePivot(new Date(), { years: -1 })
|
||||
|
||||
@ -613,7 +622,6 @@ export default {
|
||||
const urlObj = new URL(ensureProtocol(url))
|
||||
let { hostname, pathname } = urlObj
|
||||
|
||||
// remove subdomain from hostname
|
||||
const parseResult = parse(urlObj.hostname)
|
||||
if (parseResult?.subdomain?.length > 0) {
|
||||
hostname = hostname.replace(`${parseResult.subdomain}.`, '')
|
||||
@ -639,6 +647,9 @@ export default {
|
||||
} else if (urlObj.hostname === 'yewtu.be') {
|
||||
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}&?`
|
||||
} else {
|
||||
// only allow ending of mismatching search params
|
||||
similar += '(?:\\?.*)?$'
|
||||
}
|
||||
|
||||
return await itemQueryWithMeta({
|
||||
@ -647,7 +658,7 @@ export default {
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE url ~* $1
|
||||
WHERE url ~* $1 AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 3`
|
||||
}, similar)
|
||||
@ -694,7 +705,11 @@ export default {
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
outlawed: false,
|
||||
parentId: null
|
||||
parentId: null,
|
||||
OR: [
|
||||
{ invoiceActionState: 'PAID' },
|
||||
{ invoiceActionState: null }
|
||||
]
|
||||
}
|
||||
if (id) {
|
||||
where.id = { not: Number(id) }
|
||||
@ -724,6 +739,24 @@ export default {
|
||||
homeMaxBoost: homeAgg._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 }
|
||||
}
|
||||
},
|
||||
|
||||
@ -831,8 +864,16 @@ export default {
|
||||
const data = { itemId: Number(id), userId: me.id }
|
||||
const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } })
|
||||
if (old) {
|
||||
await models.threadSubscription.delete({ where: { userId_itemId: data } })
|
||||
} else await models.threadSubscription.create({ data })
|
||||
await models.$executeRaw`
|
||||
DELETE FROM "ThreadSubscription" ts
|
||||
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 }
|
||||
},
|
||||
deleteItem: async (parent, { id }, { me, models }) => {
|
||||
@ -1148,7 +1189,8 @@ export default {
|
||||
poll.meVoted = false
|
||||
}
|
||||
|
||||
poll.options = options
|
||||
poll.randPollOptions = item?.randPollOptions
|
||||
poll.options = poll.randPollOptions ? shuffleArray(options) : options
|
||||
poll.count = options.reduce((t, o) => t + o.count, 0)
|
||||
|
||||
return poll
|
||||
@ -1449,7 +1491,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
|
||||
throw new GqlInputError('item can no longer be edited')
|
||||
}
|
||||
|
||||
if (item.url && !isJob(item)) {
|
||||
if (item.url && !isJob({ subName, ...item })) {
|
||||
item.url = ensureProtocol(item.url)
|
||||
item.url = removeTracking(item.url)
|
||||
}
|
||||
@ -1464,7 +1506,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
|
||||
item = { subName, ...item }
|
||||
item.forwardUsers = await getForwardUsers(models, forward)
|
||||
}
|
||||
item.uploadIds = uploadIdsFromText(item.text, { models })
|
||||
item.uploadIds = uploadIdsFromText(item.text)
|
||||
|
||||
// never change author of item
|
||||
item.userId = old.userId
|
||||
@ -1483,7 +1525,7 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd
|
||||
item.userId = me ? Number(me.id) : USER_ID.anon
|
||||
|
||||
item.forwardUsers = await getForwardUsers(models, forward)
|
||||
item.uploadIds = uploadIdsFromText(item.text, { models })
|
||||
item.uploadIds = uploadIdsFromText(item.text)
|
||||
|
||||
if (item.url && !isJob(item)) {
|
||||
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 { getInvoice, getWithdrawl } from './wallet'
|
||||
import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
|
||||
import { replyToSubscription } from '@/lib/webPush'
|
||||
import { sendPushSubscriptionReply } from '@/lib/webPush'
|
||||
import { getSub } from './sub'
|
||||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
|
||||
@ -316,13 +316,36 @@ export default {
|
||||
|
||||
if (meFull.noteCowboyHat) {
|
||||
queries.push(
|
||||
`(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type
|
||||
`(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'CowboyHat' AS type
|
||||
FROM "Streak"
|
||||
WHERE "userId" = $1
|
||||
AND updated_at < $2
|
||||
AND type = 'COWBOY_HAT'
|
||||
ORDER BY "sortTime" DESC
|
||||
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(
|
||||
@ -416,7 +439,7 @@ export default {
|
||||
console.log(`[webPush] created subscription for user ${me.id}: endpoint=${endpoint}`)
|
||||
}
|
||||
|
||||
await replyToSubscription(dbPushSubscription.id, { title: 'Stacker News notifications are now active' })
|
||||
await sendPushSubscriptionReply(dbPushSubscription)
|
||||
|
||||
return dbPushSubscription
|
||||
},
|
||||
@ -461,8 +484,13 @@ export default {
|
||||
},
|
||||
TerritoryTransfer: {
|
||||
sub: async (n, args, { models, me }) => {
|
||||
const transfer = await models.territoryTransfer.findUnique({ where: { id: Number(n.id) }, include: { sub: true } })
|
||||
return transfer.sub
|
||||
const [sub] = await models.$queryRaw`
|
||||
SELECT "Sub".*
|
||||
FROM "TerritoryTransfer"
|
||||
JOIN "Sub" ON "Sub"."name" = "TerritoryTransfer"."subName"
|
||||
WHERE "TerritoryTransfer"."id" = ${Number(n.id)}`
|
||||
|
||||
return sub
|
||||
}
|
||||
},
|
||||
JobChanged: {
|
||||
@ -500,23 +528,14 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
Streak: {
|
||||
CowboyHat: {
|
||||
days: async (n, args, { models }) => {
|
||||
const res = await models.$queryRaw`
|
||||
SELECT "endedAt" - "startedAt" AS days
|
||||
SELECT "endedAt"::date - "startedAt"::date AS days
|
||||
FROM "Streak"
|
||||
WHERE id = ${Number(n.id)} AND "endedAt" IS NOT 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: {
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { amountSchema, validateSchema } from '@/lib/validate'
|
||||
import { getAd, getItem } from './item'
|
||||
import { topUsers } from './user'
|
||||
import performPaidAction from '../paidAction'
|
||||
import { GqlInputError } from '@/lib/error'
|
||||
|
||||
@ -152,13 +151,6 @@ export default {
|
||||
}
|
||||
},
|
||||
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 }) => {
|
||||
if (!parent.total) {
|
||||
return 0
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
||||
import { whenToFrom } from '@/lib/time'
|
||||
import { getItem, itemQueryWithMeta, SELECT } from './item'
|
||||
import { parse } from 'tldts'
|
||||
|
||||
function queryParts (q) {
|
||||
const regex = /"([^"]*)"/gm
|
||||
@ -253,24 +254,17 @@ export default {
|
||||
|
||||
// if search contains a url term, modify the query text
|
||||
if (url) {
|
||||
const uri = url.slice(4)
|
||||
let uriObj
|
||||
try {
|
||||
uriObj = new URL(uri)
|
||||
} catch {
|
||||
try {
|
||||
uriObj = new URL(`https://${uri}`)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (uriObj) {
|
||||
termQueries.push({
|
||||
wildcard: { url: `*${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}*` }
|
||||
})
|
||||
termQueries.push({
|
||||
match: { text: `${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}` }
|
||||
})
|
||||
let uri = url.slice(4)
|
||||
termQueries.push({
|
||||
match_bool_prefix: { url: { query: uri, operator: 'and', boost: 1000 } }
|
||||
})
|
||||
const parsed = parse(uri)
|
||||
if (parsed?.subdomain?.length > 0) {
|
||||
uri = uri.replace(`${parsed.subdomain}.`, '')
|
||||
}
|
||||
termQueries.push({
|
||||
wildcard: { url: { value: `*${uri}*` } }
|
||||
})
|
||||
}
|
||||
|
||||
// if nym, items must contain nym
|
||||
@ -289,25 +283,23 @@ export default {
|
||||
|
||||
// if quoted phrases, items must contain entire phrase
|
||||
for (const quote of quotes) {
|
||||
termQueries.push({
|
||||
multi_match: {
|
||||
query: quote,
|
||||
type: 'phrase',
|
||||
fields: ['title', 'text']
|
||||
}
|
||||
})
|
||||
|
||||
// force the search to include the quoted phrase
|
||||
filters.push({
|
||||
multi_match: {
|
||||
query: quote,
|
||||
fields: ['title.exact', 'text.exact'],
|
||||
type: 'phrase'
|
||||
}
|
||||
})
|
||||
termQueries.push({
|
||||
multi_match: {
|
||||
query: quote,
|
||||
fields: ['title.exact^10', 'text.exact'],
|
||||
type: 'phrase',
|
||||
fields: ['title', 'text']
|
||||
boost: 1000
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// functions for boosting search rank by recency or popularity
|
||||
switch (sort) {
|
||||
case 'comments':
|
||||
functions.push({
|
||||
@ -395,6 +387,24 @@ export default {
|
||||
fields: ['title^10', 'text'],
|
||||
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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -406,6 +416,7 @@ export default {
|
||||
if (process.env.OPENSEARCH_MODEL_ID) {
|
||||
osQuery = {
|
||||
hybrid: {
|
||||
pagination_depth: 50,
|
||||
queries: [
|
||||
{
|
||||
bool: {
|
||||
@ -457,7 +468,9 @@ export default {
|
||||
highlight: {
|
||||
fields: {
|
||||
title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
|
||||
text: { number_of_fragments: 5, order: 'score', 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.exact': { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -492,8 +505,14 @@ export default {
|
||||
orderBy: 'ORDER BY rank ASC, msats DESC'
|
||||
})).map((item, i) => {
|
||||
const e = sitems.body.hits.hits[i]
|
||||
item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title
|
||||
item.searchText = (e.highlight?.text && e.highlight.text.join(' ... ')) || undefined
|
||||
|
||||
// prefer the fuzzier highlight for title
|
||||
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
|
||||
})
|
||||
|
||||
|
@ -5,6 +5,7 @@ import { viewGroup } from './growth'
|
||||
import { notifyTerritoryTransfer } from '@/lib/webPush'
|
||||
import performPaidAction from '../paidAction'
|
||||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||
import { uploadIdsFromText } from './upload'
|
||||
|
||||
export async function getSub (parent, { name }, { models, me }) {
|
||||
if (!name) return null
|
||||
@ -35,6 +36,27 @@ export async function getSub (parent, { name }, { models, me }) {
|
||||
export default {
|
||||
Query: {
|
||||
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 }) => {
|
||||
if (me) {
|
||||
const currentUser = await models.user.findUnique({ where: { id: me.id } })
|
||||
@ -106,7 +128,7 @@ export default {
|
||||
subs
|
||||
}
|
||||
},
|
||||
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models }) => {
|
||||
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models, me }) => {
|
||||
if (!name) {
|
||||
throw new GqlInputError('must supply user name')
|
||||
}
|
||||
@ -129,26 +151,56 @@ export default {
|
||||
}
|
||||
|
||||
const subs = await models.$queryRawUnsafe(`
|
||||
SELECT "Sub".*,
|
||||
"Sub".created_at as "createdAt",
|
||||
COALESCE(floor(sum(msats_revenue)/1000), 0) as revenue,
|
||||
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
|
||||
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
|
||||
COALESCE(sum(posts), 0) as nposts,
|
||||
COALESCE(sum(comments), 0) as ncomments
|
||||
FROM ${viewGroup(range, 'sub_stats')}
|
||||
JOIN "Sub" on "Sub".name = u.sub_name
|
||||
WHERE "Sub"."userId" = $3
|
||||
AND "Sub".status = 'ACTIVE'
|
||||
GROUP BY "Sub".name
|
||||
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
|
||||
OFFSET $4
|
||||
LIMIT $5`, ...range, user.id, decodedCursor.offset, limit)
|
||||
SELECT "Sub".*,
|
||||
"Sub".created_at as "createdAt",
|
||||
COALESCE(floor(sum(msats_revenue)/1000), 0) as revenue,
|
||||
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
|
||||
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
|
||||
COALESCE(sum(posts), 0) as nposts,
|
||||
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')}
|
||||
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
|
||||
AND "Sub".status = 'ACTIVE'
|
||||
GROUP BY "Sub".name, ss."userId", ms."userId"
|
||||
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
|
||||
OFFSET $5
|
||||
LIMIT $6
|
||||
`, ...range, user.id, me?.id, decodedCursor.offset, limit)
|
||||
|
||||
return {
|
||||
cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
|
||||
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: {
|
||||
@ -159,6 +211,8 @@ export default {
|
||||
|
||||
await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } })
|
||||
|
||||
data.uploadIds = uploadIdsFromText(data.desc)
|
||||
|
||||
if (data.oldName) {
|
||||
return await updateSub(parent, data, { me, models, lnd })
|
||||
} else {
|
||||
|
@ -6,7 +6,17 @@ import { msatsToSats } from '@/lib/format'
|
||||
export default {
|
||||
Query: {
|
||||
uploadFees: async (parent, { s3Keys }, { models, me }) => {
|
||||
return uploadFees(s3Keys, { models, me })
|
||||
const fees = await 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: {
|
||||
@ -54,17 +64,36 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
export function uploadIdsFromText (text, { models }) {
|
||||
export function uploadIdsFromText (text) {
|
||||
if (!text) return []
|
||||
return [...new Set([...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])))]
|
||||
}
|
||||
|
||||
export async function uploadFees (s3Keys, { models, me }) {
|
||||
// returns info object in this format:
|
||||
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, uploadFeesMsats: BigInt }
|
||||
const [info] = await models.$queryRawUnsafe('SELECT * FROM upload_fees($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
|
||||
const uploadFees = msatsToSats(info.uploadFeesMsats)
|
||||
const totalFeesMsats = info.nUnpaid * Number(info.uploadFeesMsats)
|
||||
const totalFees = msatsToSats(totalFeesMsats)
|
||||
return { ...info, uploadFees, totalFees, totalFeesMsats }
|
||||
const [{
|
||||
bytes24h,
|
||||
bytesUnpaid,
|
||||
nUnpaid,
|
||||
uploadFeesMsats
|
||||
}] = await models.$queryRaw`SELECT * FROM upload_fees(${me?.id ?? USER_ID.anon}::INTEGER, ${s3Keys}::INTEGER[])`
|
||||
const uploadFees = BigInt(msatsToSats(uploadFeesMsats))
|
||||
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,6 +11,7 @@ import assertApiKeyNotPermitted from './apiKey'
|
||||
import { hashEmail } from '@/lib/crypto'
|
||||
import { isMuted } from '@/lib/user'
|
||||
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
|
||||
import { processCrop } from '@/worker/imgproxy'
|
||||
|
||||
const contributors = new Set()
|
||||
|
||||
@ -727,6 +728,18 @@ export default {
|
||||
|
||||
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 }) => {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
@ -898,6 +911,22 @@ export default {
|
||||
|
||||
await models.user.update({ where: { id: me.id }, data: { hideWelcomeBanner: 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
|
||||
}
|
||||
},
|
||||
|
||||
@ -1071,6 +1100,9 @@ export default {
|
||||
return false
|
||||
}
|
||||
return !!user.tipRandomMin && !!user.tipRandomMax
|
||||
},
|
||||
hideWalletRecvPrompt: async (user, args, { models }) => {
|
||||
return user.hideWalletRecvPrompt || user.hasRecvWallet
|
||||
}
|
||||
},
|
||||
|
||||
@ -1082,19 +1114,17 @@ export default {
|
||||
|
||||
return user.streak
|
||||
},
|
||||
gunStreak: async (user, args, { models }) => {
|
||||
hasSendWallet: async (user, args, { models }) => {
|
||||
if (user.hideCowboyHat) {
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
return user.gunStreak
|
||||
return user.hasSendWallet
|
||||
},
|
||||
horseStreak: async (user, args, { models }) => {
|
||||
hasRecvWallet: async (user, args, { models }) => {
|
||||
if (user.hideCowboyHat) {
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
return user.horseStreak
|
||||
return user.hasRecvWallet
|
||||
},
|
||||
maxStreak: async (user, args, { models }) => {
|
||||
if (user.hideCowboyHat) {
|
||||
@ -1102,8 +1132,9 @@ export default {
|
||||
}
|
||||
|
||||
const [{ max }] = await models.$queryRaw`
|
||||
SELECT MAX(COALESCE("endedAt", (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt")
|
||||
FROM "Streak" WHERE "userId" = ${user.id}`
|
||||
SELECT MAX(COALESCE("endedAt"::date, (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt"::date)
|
||||
FROM "Streak" WHERE "userId" = ${user.id}
|
||||
AND type = 'COWBOY_HAT'`
|
||||
return max
|
||||
},
|
||||
isContributor: async (user, args, { me }) => {
|
||||
|
@ -1,75 +0,0 @@
|
||||
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,90 +5,23 @@ import {
|
||||
import crypto, { timingSafeEqual } from 'crypto'
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
||||
import { SELECT, itemQueryWithMeta } from './item'
|
||||
import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
|
||||
import { msatsToSats, msatsToSatsDecimal } from '@/lib/format'
|
||||
import {
|
||||
USER_ID, INVOICE_RETENTION_DAYS,
|
||||
PAID_ACTION_PAYMENT_METHODS,
|
||||
WALLET_CREATE_INVOICE_TIMEOUT_MS,
|
||||
WALLET_RETRY_AFTER_MS,
|
||||
WALLET_RETRY_BEFORE_MS,
|
||||
WALLET_MAX_RETRIES
|
||||
} from '@/lib/constants'
|
||||
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
|
||||
import { validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
|
||||
import assertGofacYourself from './ofac'
|
||||
import assertApiKeyNotPermitted from './apiKey'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import { finalizeHodlInvoice } from '@/worker/wallet'
|
||||
import walletDefs from '@/wallets/server'
|
||||
import { generateResolverName, generateTypeDefName } from '@/wallets/graphql'
|
||||
import { lnAddrOptions } from '@/lib/lnurl'
|
||||
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
|
||||
import { getNodeSockets, getOurPubkey } from '../lnd'
|
||||
import validateWallet from '@/wallets/validate'
|
||||
import { canReceive, getWalletByType } from '@/wallets/common'
|
||||
import { getNodeSockets } from '../lnd'
|
||||
import performPaidAction from '../paidAction'
|
||||
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 }) {
|
||||
const inv = await models.invoice.findUnique({
|
||||
@ -154,54 +87,6 @@ export function verifyHmac (hash, hmac) {
|
||||
const resolvers = {
|
||||
Query: {
|
||||
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,
|
||||
direct: async (parent, { id }, { me, models }) => {
|
||||
if (!me) {
|
||||
@ -407,67 +292,6 @@ const resolvers = {
|
||||
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 }) => {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
@ -481,38 +305,20 @@ const resolvers = {
|
||||
AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval
|
||||
AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval
|
||||
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`
|
||||
}
|
||||
},
|
||||
Wallet: {
|
||||
wallet: async (wallet) => {
|
||||
return {
|
||||
...wallet.wallet,
|
||||
__resolveType: generateTypeDefName(wallet.type)
|
||||
}
|
||||
}
|
||||
},
|
||||
WalletDetails: {
|
||||
__resolveType: wallet => wallet.__resolveType
|
||||
},
|
||||
InvoiceOrDirect: {
|
||||
__resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType
|
||||
},
|
||||
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,
|
||||
sendToLnAddr,
|
||||
cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => {
|
||||
@ -573,43 +379,6 @@ const resolvers = {
|
||||
|
||||
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 }) => {
|
||||
return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd })
|
||||
}
|
||||
@ -753,223 +522,12 @@ const resolvers = {
|
||||
return item
|
||||
},
|
||||
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 injectResolvers(resolvers)
|
||||
export default resolvers
|
||||
|
||||
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 }) {
|
||||
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, protocol, logger }) {
|
||||
assertApiKeyNotPermitted({ me })
|
||||
await validateSchema(withdrawlSchema, { invoice, maxFee })
|
||||
await assertGofacYourself({ models, headers })
|
||||
@ -1019,10 +577,10 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
|
||||
throw new GqlInputError('SN cannot pay an invoice that SN is proxying')
|
||||
}
|
||||
|
||||
return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd })
|
||||
return await performPayingAction({ bolt11: invoice, maxFee, protocolId: protocol?.id }, { me, models, lnd })
|
||||
}
|
||||
|
||||
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
|
||||
async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
|
||||
{ me, models, lnd, headers }) {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
@ -1040,11 +598,9 @@ export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...
|
||||
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
|
||||
}
|
||||
|
||||
export async function fetchLnAddrInvoice (
|
||||
async function fetchLnAddrInvoice (
|
||||
{ addr, amount, maxFee, comment, ...payer },
|
||||
{
|
||||
me, models, lnd, autoWithdraw = false
|
||||
}) {
|
||||
{ me, models, lnd }) {
|
||||
const options = await lnAddrOptions(addr)
|
||||
await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
|
||||
|
||||
@ -1081,14 +637,6 @@ export async function fetchLnAddrInvoice (
|
||||
// decode invoice
|
||||
try {
|
||||
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)) {
|
||||
throw new Error('invoice has incorrect amount')
|
||||
}
|
||||
|
@ -1,17 +0,0 @@
|
||||
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 { NOFOLLOW_LIMIT } from '@/lib/constants'
|
||||
import { satsToMsats } from '@/lib/format'
|
||||
import { MULTI_AUTH_ANON, MULTI_AUTH_LIST } from '@/lib/auth'
|
||||
import { MULTI_AUTH_ANON, MULTI_AUTH_POINTER } from '@/lib/auth'
|
||||
|
||||
export default async function getSSRApolloClient ({ req, res, me = null }) {
|
||||
const session = req && await getServerSession(req, res, getAuthOptions(req))
|
||||
@ -156,7 +156,7 @@ export function getGetServerSideProps (
|
||||
|
||||
// required to redirect to /signup on page reload
|
||||
// if we switched to anon and authentication is required
|
||||
if (req.cookies[MULTI_AUTH_LIST] === MULTI_AUTH_ANON) {
|
||||
if (req.cookies[MULTI_AUTH_POINTER] === MULTI_AUTH_ANON) {
|
||||
me = null
|
||||
}
|
||||
|
||||
|
@ -18,7 +18,6 @@ import admin from './admin'
|
||||
import blockHeight from './blockHeight'
|
||||
import chainFee from './chainFee'
|
||||
import paidAction from './paidAction'
|
||||
import vault from './vault'
|
||||
|
||||
const common = gql`
|
||||
type Query {
|
||||
@ -39,4 +38,4 @@ const common = gql`
|
||||
`
|
||||
|
||||
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
|
||||
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault]
|
||||
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction]
|
||||
|
@ -11,6 +11,7 @@ export default gql`
|
||||
auctionPosition(sub: String, id: ID, boost: Int): Int!
|
||||
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
|
||||
itemRepetition(parentId: ID): Int!
|
||||
newComments(rootId: ID, after: Date): Comments!
|
||||
}
|
||||
|
||||
type BoostPositions {
|
||||
@ -57,7 +58,7 @@ export default gql`
|
||||
text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction!
|
||||
upsertPoll(
|
||||
id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date,
|
||||
hash: String, hmac: String): ItemPaidAction!
|
||||
randPollOptions: Boolean, hash: String, hmac: String): ItemPaidAction!
|
||||
updateNoteId(id: ID!, noteId: String!): Item!
|
||||
upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction!
|
||||
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
|
||||
@ -81,6 +82,7 @@ export default gql`
|
||||
meInvoiceActionState: InvoiceActionState
|
||||
count: Int!
|
||||
options: [PollOption!]!
|
||||
randPollOptions: Boolean
|
||||
}
|
||||
|
||||
type Items {
|
||||
@ -147,6 +149,7 @@ export default gql`
|
||||
ncomments: Int!
|
||||
nDirectComments: Int!
|
||||
comments(sort: String, cursor: String): Comments!
|
||||
injected: Boolean!
|
||||
path: String
|
||||
position: Int
|
||||
prior: Int
|
||||
|
@ -75,13 +75,6 @@ export default gql`
|
||||
tipComments: Int!
|
||||
}
|
||||
|
||||
type Streak {
|
||||
id: ID!
|
||||
sortTime: Date!
|
||||
days: Int
|
||||
type: String!
|
||||
}
|
||||
|
||||
type Earn {
|
||||
id: ID!
|
||||
earnedSats: Int!
|
||||
@ -156,11 +149,37 @@ export default gql`
|
||||
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
|
||||
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
|
||||
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
|
||||
| FollowActivity | ForwardedVotification | Revenue | SubStatus
|
||||
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification
|
||||
| ReferralReward
|
||||
| ReferralReward | CowboyHat | NewHorse | LostHorse | NewGun | LostGun
|
||||
|
||||
type Notifications {
|
||||
lastChecked: Date
|
||||
|
@ -18,7 +18,6 @@ export default gql`
|
||||
total: Int!
|
||||
time: Date!
|
||||
sources: [NameValue!]!
|
||||
leaderboard: UsersNullable
|
||||
ad: Item
|
||||
}
|
||||
|
||||
|
@ -7,6 +7,8 @@ export default gql`
|
||||
subs: [Sub!]!
|
||||
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
|
||||
mySubscribedSubs(cursor: String): Subs
|
||||
subSuggestions(q: String!, limit: Limit): [Sub!]!
|
||||
}
|
||||
|
||||
type Subs {
|
||||
|
@ -29,21 +29,34 @@ export default gql`
|
||||
users: [User!]!
|
||||
}
|
||||
|
||||
input CropData {
|
||||
x: Float!
|
||||
y: Float!
|
||||
width: Float!
|
||||
height: Float!
|
||||
originalWidth: Int!
|
||||
originalHeight: Int!
|
||||
scale: Float!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
setName(name: String!): String
|
||||
setSettings(settings: SettingsInput!): User
|
||||
cropPhoto(photoId: ID!, cropData: CropData): String!
|
||||
setPhoto(photoId: ID!): Int!
|
||||
upsertBio(text: String!): ItemPaidAction!
|
||||
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
|
||||
unlinkAuth(authType: String!): AuthMethods!
|
||||
linkUnverifiedEmail(email: String!): Boolean
|
||||
hideWelcomeBanner: Boolean
|
||||
hideWalletRecvPrompt: Boolean
|
||||
subscribeUserPosts(id: ID): User
|
||||
subscribeUserComments(id: ID): User
|
||||
toggleMute(id: ID): User
|
||||
generateApiKey(id: ID!): String
|
||||
deleteApiKey(id: ID!): User
|
||||
disableFreebies: Boolean
|
||||
setDiagnostics(diagnostics: Boolean!): Boolean
|
||||
}
|
||||
|
||||
type User {
|
||||
@ -74,7 +87,6 @@ export default gql`
|
||||
|
||||
input SettingsInput {
|
||||
autoDropBolt11s: Boolean!
|
||||
diagnostics: Boolean!
|
||||
noReferralLinks: Boolean!
|
||||
fiatCurrency: String!
|
||||
satsFilter: Int!
|
||||
@ -112,10 +124,6 @@ export default gql`
|
||||
zapUndos: Int
|
||||
wildWestMode: Boolean!
|
||||
withdrawMaxFeeDefault: Int!
|
||||
proxyReceive: Boolean
|
||||
directReceive: Boolean
|
||||
receiveCreditsBelowSats: Int!
|
||||
sendCreditsBelowSats: Int!
|
||||
}
|
||||
|
||||
type AuthMethods {
|
||||
@ -141,16 +149,18 @@ export default gql`
|
||||
"""
|
||||
lastCheckedJobs: String
|
||||
hideWelcomeBanner: Boolean!
|
||||
hideWalletRecvPrompt: Boolean!
|
||||
tipPopover: Boolean!
|
||||
upvotePopover: Boolean!
|
||||
hasInvites: Boolean!
|
||||
apiKeyEnabled: Boolean!
|
||||
showPassphrase: Boolean!
|
||||
diagnostics: Boolean!
|
||||
|
||||
"""
|
||||
mirrors SettingsInput
|
||||
"""
|
||||
autoDropBolt11s: Boolean!
|
||||
diagnostics: Boolean!
|
||||
noReferralLinks: Boolean!
|
||||
fiatCurrency: String!
|
||||
satsFilter: Int!
|
||||
@ -191,14 +201,9 @@ export default gql`
|
||||
wildWestMode: Boolean!
|
||||
withdrawMaxFeeDefault: Int!
|
||||
autoWithdrawThreshold: Int
|
||||
autoWithdrawMaxFeePercent: Float
|
||||
autoWithdrawMaxFeeTotal: Int
|
||||
vaultKeyHash: String
|
||||
vaultKeyHashUpdatedAt: Date
|
||||
walletsUpdatedAt: Date
|
||||
proxyReceive: Boolean
|
||||
directReceive: Boolean
|
||||
receiveCreditsBelowSats: Int!
|
||||
sendCreditsBelowSats: Int!
|
||||
}
|
||||
|
||||
type UserOptional {
|
||||
@ -211,6 +216,9 @@ export default gql`
|
||||
streak: Int
|
||||
gunStreak: Int
|
||||
horseStreak: Int
|
||||
hasSendWallet: Boolean
|
||||
hasRecvWallet: Boolean
|
||||
hideWalletRecvPrompt: Boolean
|
||||
maxStreak: Int
|
||||
isContributor: Boolean
|
||||
githubId: String
|
||||
|
@ -1,29 +0,0 @@
|
||||
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,66 +1,8 @@
|
||||
import { gql } from 'graphql-tag'
|
||||
import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName } from '@/wallets/graphql'
|
||||
import { isServerField } from '@/wallets/common'
|
||||
import walletDefs from '@/wallets/server'
|
||||
|
||||
function injectTypeDefs (typeDefs) {
|
||||
const injected = [rawTypeDefs(), mutationTypeDefs()]
|
||||
return `${typeDefs}\n\n${injected.join('\n\n')}\n`
|
||||
}
|
||||
const shared = 'walletId: ID, templateName: ID, enabled: Boolean!'
|
||||
|
||||
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 = `
|
||||
const typeDefs = gql`
|
||||
extend type Query {
|
||||
invoice(id: ID!): Invoice!
|
||||
withdrawl(id: ID!): Withdrawl!
|
||||
@ -68,23 +10,151 @@ const typeDefs = `
|
||||
numBolt11s: Int!
|
||||
connectAddress: String!
|
||||
walletHistory(cursor: String, inc: String): History
|
||||
wallets(includeReceivers: Boolean, includeSenders: Boolean, onlyEnabled: Boolean, prioritySort: String): [Wallet!]!
|
||||
wallet(id: ID!): Wallet
|
||||
walletByType(type: String!): Wallet
|
||||
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
|
||||
wallets: [WalletOrTemplate!]!
|
||||
wallet(id: ID, name: String): WalletOrTemplate
|
||||
walletSettings: WalletSettings!
|
||||
walletLogs(protocolId: Int, cursor: String, debug: Boolean): WalletLogs!
|
||||
failedInvoices: [Invoice!]!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
createInvoice(amount: Int!): InvoiceOrDirect!
|
||||
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
|
||||
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
|
||||
cancelInvoice(hash: String!, hmac: String, userCancel: Boolean): Invoice!
|
||||
dropBolt11(hash: String!): Boolean
|
||||
removeWallet(id: ID!): Boolean
|
||||
deleteWalletLogs(wallet: String): Boolean
|
||||
setWalletPriority(id: ID!, priority: Int!): Boolean
|
||||
buyCredits(credits: Int!): BuyCreditsPaidAction!
|
||||
|
||||
# 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
|
||||
removeWalletProtocol(id: ID!): Boolean
|
||||
|
||||
# crypto
|
||||
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 {
|
||||
@ -95,15 +165,155 @@ const typeDefs = `
|
||||
id: ID!
|
||||
}
|
||||
|
||||
union WalletOrTemplate = Wallet | WalletTemplate
|
||||
|
||||
enum WalletStatus {
|
||||
OK
|
||||
WARNING
|
||||
ERROR
|
||||
DISABLED
|
||||
}
|
||||
|
||||
type Wallet {
|
||||
id: ID!
|
||||
createdAt: Date!
|
||||
updatedAt: Date!
|
||||
type: String!
|
||||
enabled: Boolean!
|
||||
name: String!
|
||||
priority: Int!
|
||||
wallet: WalletDetails!
|
||||
vaultEntries: [VaultEntry!]!
|
||||
template: WalletTemplate!
|
||||
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!
|
||||
config: WalletProtocolConfig!
|
||||
status: WalletStatus!
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -112,6 +322,22 @@ const typeDefs = `
|
||||
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 {
|
||||
id: ID!
|
||||
createdAt: Date!
|
||||
@ -186,7 +412,7 @@ const typeDefs = `
|
||||
cursor: String
|
||||
}
|
||||
|
||||
type WalletLog {
|
||||
type WalletLogs {
|
||||
entries: [WalletLogEntry!]!
|
||||
cursor: String
|
||||
}
|
||||
@ -194,11 +420,25 @@ const typeDefs = `
|
||||
type WalletLogEntry {
|
||||
id: ID!
|
||||
createdAt: Date!
|
||||
wallet: ID!
|
||||
wallet: Wallet
|
||||
protocol: WalletProtocol
|
||||
level: String!
|
||||
message: String!
|
||||
context: JSONObject
|
||||
}
|
||||
`
|
||||
|
||||
export default gql`${injectTypeDefs(typeDefs)}`
|
||||
type VaultEntry {
|
||||
id: ID!
|
||||
iv: String!
|
||||
value: String!
|
||||
createdAt: Date!
|
||||
updatedAt: Date!
|
||||
}
|
||||
|
||||
input VaultEntryInput {
|
||||
iv: String!
|
||||
value: String!
|
||||
keyHash: String!
|
||||
}
|
||||
`
|
||||
export default typeDefs
|
||||
|
89
awards.csv
89
awards.csv
@ -177,31 +177,84 @@ 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
|
||||
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
|
||||
dtonon,issue,#1928,#1924,good-first-issue,,,,2k,???,???
|
||||
dtonon,issue,#1928,#1924,good-first-issue,,,,2k,tips@dtonon.com,2025-04-16
|
||||
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,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,#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
|
||||
itsrealfake,issue,#1930,#1167,good-first-issue,,,,2k,smallimagination100035@getalby.com,???
|
||||
itsrealfake,issue,#1930,#1167,good-first-issue,,,,2k,smallimagination100035@getalby.com,2025-04-02
|
||||
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
|
||||
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
|
||||
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,???
|
||||
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,???,???
|
||||
ed-kung,pr,#2012,#2004,easy,,,,100k,simplestacker@getalby.com,???
|
||||
ed-kung,issue,#2012,#2004,easy,,,,10k,simplestacker@getalby.com,???
|
||||
ed-kung,pr,#1993,#1982,good-first-issue,,,,20k,simplestacker@getalby.com,???
|
||||
rideandslide,issue,#1993,#1982,good-first-issue,,,,2k,???,???
|
||||
ed-kung,pr,#1972,#1254,good-first-issue,,,,20k,simplestacker@getalby.com,???
|
||||
SatsAllDay,issue,#1972,#1254,good-first-issue,,,,2k,weareallsatoshi@getalby.com,???
|
||||
ed-kung,pr,#1962,#1343,good-first-issue,,,,20k,simplestacker@getalby.com,???
|
||||
ed-kung,pr,#1962,#1217,good-first-issue,,,,20k,simplestacker@getalby.com,???
|
||||
ed-kung,pr,#1962,#866,easy,,,,100k,simplestacker@getalby.com,???
|
||||
felipebueno,issue,#1962,#866,easy,,,,10k,felipebueno@blink.sv,???
|
||||
cointastical,issue,#1962,#1217,good-first-issue,,,,2k,cointastical@stacker.news,???
|
||||
Scroogey-SN,pr,#1975,#1964,good-first-issue,,,,20k,Scroogey@coinos.io,???
|
||||
rideandslide,issue,#1986,#1985,good-first-issue,,,,2k,???,???
|
||||
kristapsk,issue,#1976,#841,good-first-issue,,,,2k,???,???
|
||||
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,2025-04-02
|
||||
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,me@benthecarman.com,2025-04-16
|
||||
ed-kung,pr,#2012,#2004,easy,,,,100k,simplestacker@getalby.com,2025-04-02
|
||||
ed-kung,issue,#2012,#2004,easy,,,,10k,simplestacker@getalby.com,2025-04-02
|
||||
ed-kung,pr,#1993,#1982,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
|
||||
rideandslide,issue,#1993,#1982,good-first-issue,,,,2k,koiora@getalby.com,2025-04-02
|
||||
ed-kung,pr,#1972,#1254,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
|
||||
SatsAllDay,issue,#1972,#1254,good-first-issue,,,,2k,weareallsatoshi@getalby.com,2025-04-02
|
||||
ed-kung,pr,#1962,#1343,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
|
||||
ed-kung,pr,#1962,#1217,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
|
||||
ed-kung,pr,#1962,#866,easy,,,,100k,simplestacker@getalby.com,2025-04-02
|
||||
felipebueno,issue,#1962,#866,easy,,,,10k,felipebueno@blink.sv,2025-04-02
|
||||
cointastical,issue,#1962,#1217,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
|
||||
Scroogey-SN,pr,#1975,#1964,good-first-issue,,,,20k,Scroogey@coinos.io,2025-04-02
|
||||
rideandslide,issue,#1986,#1985,good-first-issue,,,,2k,koiora@getalby.com,2025-04-02
|
||||
kristapsk,issue,#1976,#841,good-first-issue,,,,2k,kristapsk@stacker.news,2025-04-16
|
||||
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": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"express": "^4.20.0",
|
||||
"puppeteer": "^20.8.2"
|
||||
},
|
||||
"type": "module"
|
||||
|
@ -46,7 +46,7 @@ export default function AccordianItem ({ header, body, className, headerColor =
|
||||
<Accordion defaultActiveKey={activeKey} activeKey={activeKey} onSelect={handleOnSelect}>
|
||||
<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)}>
|
||||
<div>{body}</div>
|
||||
<div key={activeKey}>{body}</div>
|
||||
</Accordion.Collapse>
|
||||
</Accordion>
|
||||
)
|
||||
|
@ -1,165 +1,44 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import * as cookie from 'cookie'
|
||||
import { useMe } from '@/components/me'
|
||||
import { USER_ID, SSR } from '@/lib/constants'
|
||||
import { USER_ID } from '@/lib/constants'
|
||||
import { USER } from '@/fragments/users'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { UserListRow } from '@/components/user-list'
|
||||
import useCookie from '@/components/use-cookie'
|
||||
import Link from 'next/link'
|
||||
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'
|
||||
|
||||
const AccountContext = createContext()
|
||||
|
||||
const CHECK_ERRORS_INTERVAL_MS = 5_000
|
||||
|
||||
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
|
||||
|
||||
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' })
|
||||
// if status is 302, this means the server was able to switch us to the next available account
|
||||
// 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 const nextAccount = async () => {
|
||||
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
|
||||
return status === 302
|
||||
}
|
||||
|
||||
export default function SwitchAccountList () {
|
||||
const { accounts, multiAuthErrors } = useAccounts()
|
||||
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 (
|
||||
<>
|
||||
<div className='my-2'>
|
||||
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
|
||||
<h4 className='text-muted'>Accounts</h4>
|
||||
<AccountListRow account={{ id: USER_ID.anon, name: 'anon' }} showHat={false} />
|
||||
<AccountListRow
|
||||
account={{ id: USER_ID.anon, name: 'anon' }}
|
||||
selected={pointerCookie === MULTI_AUTH_ANON}
|
||||
showHat={false}
|
||||
/>
|
||||
{
|
||||
accounts.map((account) => <AccountListRow key={account.id} account={account} showHat={false} />)
|
||||
accounts.map((account) =>
|
||||
<AccountListRow
|
||||
key={account.id}
|
||||
account={account}
|
||||
selected={Number(pointerCookie) === account.id}
|
||||
showHat={false}
|
||||
/>)
|
||||
}
|
||||
</div>
|
||||
<Link
|
||||
@ -175,3 +54,45 @@ 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 { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
|
||||
import InputGroup from 'react-bootstrap/InputGroup'
|
||||
import { BOOST_MIN, BOOST_MULT, MAX_FORWARDS, SSR } from '@/lib/constants'
|
||||
import { BOOST_MIN, BOOST_MAX, BOOST_MULT, MAX_FORWARDS, SSR } from '@/lib/constants'
|
||||
import { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
|
||||
import Info from './info'
|
||||
import { abbrNum, numWithUnits } from '@/lib/format'
|
||||
@ -37,6 +37,7 @@ 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 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 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)
|
||||
<ul>
|
||||
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li>
|
||||
@ -197,7 +198,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
|
||||
for (let i = 0; i < MAX_FORWARDS; i++) {
|
||||
['nym', 'pct'].forEach(key => {
|
||||
const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`)
|
||||
if (value) {
|
||||
if (value !== undefined && value !== null) {
|
||||
formik?.setFieldValue(`forward[${i}].${key}`, value)
|
||||
}
|
||||
})
|
||||
@ -268,7 +269,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
|
||||
emptyItem={EMPTY_FORWARD}
|
||||
hint={<span className='text-muted'>Forward sats to up to 5 other stackers. Any remaining sats go to you.</span>}
|
||||
>
|
||||
{({ index, placeholder }) => {
|
||||
{({ index, AppendColumn }) => {
|
||||
return (
|
||||
<div key={index} className='d-flex flex-row'>
|
||||
<InputUserSuggest
|
||||
@ -285,6 +286,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
|
||||
max={100}
|
||||
append={<InputGroup.Text className='text-monospace'>%</InputGroup.Text>}
|
||||
groupClassName={`${styles.percent} mb-0`}
|
||||
AppendColumn={AppendColumn}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
275
components/animation/fireworks.js
Normal file
275
components/animation/fireworks.js
Normal file
@ -0,0 +1,275 @@
|
||||
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')
|
||||
}
|
8
components/animation/fireworks.module.css
Normal file
8
components/animation/fireworks.module.css
Normal file
@ -0,0 +1,8 @@
|
||||
.fireworks {
|
||||
z-index: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
72
components/animation/index.js
Normal file
72
components/animation/index.js
Normal file
@ -0,0 +1,72 @@
|
||||
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
|
||||
}
|
@ -13,16 +13,11 @@ export class LightningProvider extends React.Component {
|
||||
* @returns boolean indicating whether the strike actually happened, based on user preferences
|
||||
*/
|
||||
strike = () => {
|
||||
const should = window.localStorage.getItem('lnAnimate') || 'yes'
|
||||
if (should === 'yes') {
|
||||
this.setState(state => {
|
||||
return {
|
||||
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
this.setState(state => {
|
||||
return {
|
||||
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
unstrike = (index) => {
|
@ -11,22 +11,17 @@ export const SnowProvider = ({ children }) => {
|
||||
const [flakes, setFlakes] = useState(Array(1024))
|
||||
|
||||
const snow = useCallback(() => {
|
||||
const should = window.localStorage.getItem('lnAnimate') || 'yes'
|
||||
if (should === 'yes') {
|
||||
// amount of flakes to add
|
||||
const n = Math.floor(randInRange(5, 30))
|
||||
const newFlakes = [...flakes]
|
||||
let i
|
||||
for (i = startIndex; i < (startIndex + n); ++i) {
|
||||
const key = startIndex + i
|
||||
newFlakes[i % MAX_FLAKES] = <Snow key={key} />
|
||||
}
|
||||
setStartIndex(i % MAX_FLAKES)
|
||||
setFlakes(newFlakes)
|
||||
return true
|
||||
// amount of flakes to add
|
||||
const n = Math.floor(randInRange(5, 30))
|
||||
const newFlakes = [...flakes]
|
||||
let i
|
||||
for (i = startIndex; i < (startIndex + n); ++i) {
|
||||
const key = startIndex + i
|
||||
newFlakes[i % MAX_FLAKES] = <Snow key={key} />
|
||||
}
|
||||
return false
|
||||
}, [setFlakes, startIndex])
|
||||
setStartIndex(i % MAX_FLAKES)
|
||||
setFlakes(newFlakes)
|
||||
}, [flakes, startIndex])
|
||||
|
||||
return (
|
||||
<SnowContext.Provider value={snow}>
|
@ -1,76 +0,0 @@
|
||||
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,12 +6,18 @@ import EditImage from '@/svgs/image-edit-fill.svg'
|
||||
import Moon from '@/svgs/moon-fill.svg'
|
||||
import { useShowModal } from './modal'
|
||||
import { FileUpload } from './file-upload'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
|
||||
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 showModal = useShowModal()
|
||||
|
||||
const Body = ({ onClose, file, upload }) => {
|
||||
const Body = ({ onClose, file, onSave }) => {
|
||||
const [scale, setScale] = useState(1)
|
||||
const ref = useRef()
|
||||
|
||||
@ -34,13 +40,21 @@ export default function Avatar ({ onSuccess }) {
|
||||
/>
|
||||
</BootstrapForm.Group>
|
||||
<Button
|
||||
onClick={() => {
|
||||
ref.current.getImageScaledToCanvas().toBlob(blob => {
|
||||
if (blob) {
|
||||
upload(blob)
|
||||
onClose()
|
||||
onClick={async () => {
|
||||
const rect = ref.current.getCroppingRect()
|
||||
const img = new window.Image()
|
||||
img.onload = async () => {
|
||||
const cropData = {
|
||||
...rect,
|
||||
originalWidth: img.width,
|
||||
originalHeight: img.height,
|
||||
scale
|
||||
}
|
||||
}, 'image/jpeg')
|
||||
// upload original to S3 along with crop data
|
||||
await onSave(cropData)
|
||||
}
|
||||
img.src = URL.createObjectURL(file)
|
||||
onClose()
|
||||
}}
|
||||
>save
|
||||
</Button>
|
||||
@ -48,6 +62,45 @@ 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 (
|
||||
<FileUpload
|
||||
allow='image/*'
|
||||
@ -56,26 +109,7 @@ export default function Avatar ({ onSuccess }) {
|
||||
console.log(e)
|
||||
setUploading(false)
|
||||
}}
|
||||
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)
|
||||
}}
|
||||
onSelect={startCrop}
|
||||
onUpload={() => {
|
||||
setUploading(true)
|
||||
}}
|
||||
|
@ -1,29 +1,14 @@
|
||||
import { Fragment } from 'react'
|
||||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
|
||||
import Tooltip from 'react-bootstrap/Tooltip'
|
||||
import CowboyHatIcon from '@/svgs/cowboy.svg'
|
||||
import AnonIcon from '@/svgs/spy-fill.svg'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
import { USER_ID } from '@/lib/constants'
|
||||
import GunIcon from '@/svgs/revolver.svg'
|
||||
import HorseIcon from '@/svgs/horse.svg'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
import { USER_ID } from '@/lib/constants'
|
||||
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 }) {
|
||||
if (!user || Number(user.id) === USER_ID.ad) return null
|
||||
if (Number(user.id) === USER_ID.anon) {
|
||||
@ -34,14 +19,43 @@ 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 (
|
||||
<span className={className}>
|
||||
{BADGES.map(({ icon, streakName, sizeDelta }, i) => (
|
||||
{badges.map(({ icon, overlayText, sizeDelta }, i) => (
|
||||
<SNBadge
|
||||
key={streakName}
|
||||
key={i}
|
||||
user={user}
|
||||
badge={badge}
|
||||
streakName={streakName}
|
||||
overlayText={overlayText}
|
||||
badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
|
||||
IconForBadge={icon}
|
||||
height={height}
|
||||
@ -53,20 +67,19 @@ export default function Badges ({ user, badge, className = 'ms-1', badgeClassNam
|
||||
)
|
||||
}
|
||||
|
||||
function SNBadge ({ user, badge, streakName, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
|
||||
const streak = user.optional[streakName]
|
||||
if (streak === null) {
|
||||
return null
|
||||
function SNBadge ({ user, badge, overlayText, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
|
||||
let Wrapper = Fragment
|
||||
|
||||
if (overlayText) {
|
||||
Wrapper = ({ children }) => (
|
||||
<BadgeTooltip overlayText={overlayText}>{children}</BadgeTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BadgeTooltip
|
||||
overlayText={streak
|
||||
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
|
||||
: 'new'}
|
||||
>
|
||||
<Wrapper>
|
||||
<span><IconForBadge className={badgeClassName} height={height + sizeDelta} width={width + sizeDelta} /></span>
|
||||
</BadgeTooltip>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -5,8 +5,6 @@ import { useMe } from '@/components/me'
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
|
||||
import { useToast } from '@/components/toast'
|
||||
import Link from 'next/link'
|
||||
import AccordianItem from '@/components/accordian-item'
|
||||
|
||||
export function WelcomeBanner ({ Banner }) {
|
||||
const { me } = useMe()
|
||||
@ -101,22 +99,6 @@ 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 () {
|
||||
return (
|
||||
<Alert className={`${styles.banner} mt-0`} key='info' variant='danger'>
|
||||
@ -124,24 +106,3 @@ export function AuthBanner () {
|
||||
</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,12 +53,19 @@ function useArrowKeys ({ moveLeft, moveRight }) {
|
||||
}, [onKeyDown])
|
||||
}
|
||||
|
||||
export default function Carousel ({ close, mediaArr, src, originalSrc, setOptions }) {
|
||||
function Carousel ({ close, mediaArr, src, setOptions }) {
|
||||
const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src))
|
||||
const [currentSrc, canGoLeft, canGoRight] = useMemo(() => {
|
||||
return [mediaArr[index][0], index > 0, index < mediaArr.length - 1]
|
||||
}, [mediaArr, index])
|
||||
|
||||
useEffect(() => {
|
||||
if (index === -1) return
|
||||
setOptions({
|
||||
overflow: <CarouselOverflow {...mediaArr[index][1]} />
|
||||
})
|
||||
}, [index, mediaArr, setOptions])
|
||||
|
||||
const moveLeft = useCallback(() => {
|
||||
setIndex(i => Math.max(0, i - 1))
|
||||
}, [setIndex])
|
||||
@ -114,15 +121,15 @@ export function CarouselProvider ({ children }) {
|
||||
fullScreen: true,
|
||||
overflow: <CarouselOverflow {...media.current.get(src)} />
|
||||
})
|
||||
}, [showModal, media.current])
|
||||
}, [showModal])
|
||||
|
||||
const addMedia = useCallback(({ src, originalSrc, rel }) => {
|
||||
media.current.set(src, { src, originalSrc, rel })
|
||||
}, [media.current])
|
||||
}, [])
|
||||
|
||||
const removeMedia = useCallback((src) => {
|
||||
media.current.delete(src)
|
||||
}, [media.current])
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({ showCarousel, addMedia, removeMedia }), [showCarousel, addMedia, removeMedia])
|
||||
return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider>
|
||||
|
@ -96,7 +96,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
|
||||
}
|
||||
|
||||
export default function Comment ({
|
||||
item, children, replyOpen, includeParent, topLevel,
|
||||
item, children, replyOpen, includeParent, topLevel, rootLastCommentAt,
|
||||
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
|
||||
}) {
|
||||
const [edit, setEdit] = useState()
|
||||
@ -114,6 +114,17 @@ export default function Comment ({
|
||||
|
||||
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(() => {
|
||||
const comment = cache.readFragment({
|
||||
id: `Item:${router.query.commentId}`,
|
||||
@ -140,12 +151,29 @@ export default function Comment ({
|
||||
}, [item.id, cache, router.query.commentId])
|
||||
|
||||
useEffect(() => {
|
||||
if (router.query.commentsViewedAt &&
|
||||
me?.id !== item.user?.id &&
|
||||
new Date(item.createdAt).getTime() > router.query.commentsViewedAt) {
|
||||
if (me?.id === item.user?.id) return
|
||||
|
||||
const itemCreatedAt = new Date(item.createdAt).getTime()
|
||||
// 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')
|
||||
}
|
||||
}, [item.id])
|
||||
}, [item.id, rootLastCommentAt])
|
||||
|
||||
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
|
||||
@ -159,17 +187,19 @@ export default function Comment ({
|
||||
return (
|
||||
<div
|
||||
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
|
||||
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
|
||||
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
|
||||
onMouseEnter={unsetOutline}
|
||||
onTouchStart={unsetOutline}
|
||||
>
|
||||
<div className={`${itemStyles.item} ${styles.item}`}>
|
||||
{item.outlawed && !me?.privates?.wildWestMode
|
||||
? <Skull className={styles.dontLike} width={24} height={24} />
|
||||
: item.mine
|
||||
? <Boost item={item} className={styles.upvote} />
|
||||
: item.meDontLikeSats > item.meSats
|
||||
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} collapsed={collapse === 'yep'} />}
|
||||
: pin
|
||||
? <Pin width={22} height={22} className={styles.pin} />
|
||||
: item.mine
|
||||
? <Boost item={item} className={styles.upvote} />
|
||||
: item.meDontLikeSats > item.meSats
|
||||
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||
: <UpVote item={item} className={styles.upvote} collapsed={collapse === 'yep'} />}
|
||||
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
||||
<div className='d-flex align-items-center'>
|
||||
{item.user?.meMute && !includeParent && collapse === 'yep'
|
||||
@ -182,6 +212,7 @@ export default function Comment ({
|
||||
>reply from someone you muted
|
||||
</span>)
|
||||
: <ItemInfo
|
||||
full={topLevel}
|
||||
item={item}
|
||||
commentsText='replies'
|
||||
commentTextSingular='reply'
|
||||
@ -249,7 +280,7 @@ export default function Comment ({
|
||||
</div>
|
||||
{collapse !== 'yep' && (
|
||||
bottomedOut
|
||||
? <div className={styles.children}><div className={classNames(styles.comment, 'mt-3')}><ReplyOnAnotherPage item={item} /></div></div>
|
||||
? <div className={styles.children}><div className={classNames(styles.comment, 'mt-3 pb-2')}><ViewMoreReplies item={item} threadContext /></div></div>
|
||||
: (
|
||||
<div className={styles.children}>
|
||||
{item.outlawed && !me?.privates?.wildWestMode
|
||||
@ -264,9 +295,13 @@ export default function Comment ({
|
||||
? (
|
||||
<>
|
||||
{item.comments.comments.map((item) => (
|
||||
<Comment depth={depth + 1} key={item.id} item={item} />
|
||||
<Comment depth={depth + 1} key={item.id} item={item} rootLastCommentAt={rootLastCommentAt} />
|
||||
))}
|
||||
{item.comments.comments.length < item.nDirectComments && <ViewAllReplies id={item.id} nhas={item.ncomments} />}
|
||||
{item.comments.comments.length < item.nDirectComments && (
|
||||
<div className={`d-block ${styles.comment} pb-2 ps-3`}>
|
||||
<ViewMoreReplies item={item} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
@ -279,29 +314,24 @@ export default function Comment ({
|
||||
)
|
||||
}
|
||||
|
||||
export function ViewAllReplies ({ id, nshown, nhas }) {
|
||||
const text = `view all ${nhas} replies`
|
||||
|
||||
return (
|
||||
<div className={`d-block fw-bold ${styles.comment} pb-2 ps-3`}>
|
||||
<Link href={`/items/${id}`} as={`/items/${id}`} className='text-muted'>
|
||||
{text}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReplyOnAnotherPage ({ item }) {
|
||||
export function ViewMoreReplies ({ item, threadContext = false }) {
|
||||
const root = useRoot()
|
||||
const rootId = commentSubTreeRootId(item, root)
|
||||
const id = threadContext ? commentSubTreeRootId(item, root) : item.id
|
||||
|
||||
let text = 'reply on another page'
|
||||
if (item.ncomments > 0) {
|
||||
text = `view all ${item.ncomments} replies`
|
||||
}
|
||||
// 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 (
|
||||
<Link href={`/items/${rootId}?commentId=${item.id}`} as={`/items/${rootId}`} className='d-block pb-2 fw-bold text-muted'>
|
||||
<Link
|
||||
href={href}
|
||||
as={`/items/${id}`}
|
||||
className='fw-bold d-flex align-items-center gap-2 text-muted'
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
)
|
||||
|
@ -135,4 +135,36 @@
|
||||
|
||||
.comment:has(.comment) + .comment{
|
||||
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,6 +8,7 @@ import { defaultCommentSort } from '@/lib/item'
|
||||
import { useRouter } from 'next/router'
|
||||
import MoreFooter from './more-footer'
|
||||
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
|
||||
import useLiveComments from './use-live-comments'
|
||||
|
||||
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
||||
const router = useRouter()
|
||||
@ -64,10 +65,13 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
|
||||
|
||||
export default function Comments ({
|
||||
parentId, pinned, bio, parentCreatedAt,
|
||||
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, ...props
|
||||
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, lastCommentAt, item, ...props
|
||||
}) {
|
||||
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])
|
||||
|
||||
return (
|
||||
@ -90,11 +94,11 @@ export default function Comments ({
|
||||
: null}
|
||||
{pins.map(item => (
|
||||
<Fragment key={item.id}>
|
||||
<Comment depth={1} item={item} {...props} pin />
|
||||
<Comment depth={1} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin />
|
||||
</Fragment>
|
||||
))}
|
||||
{comments.filter(({ position }) => !position).map(item => (
|
||||
<Comment depth={1} key={item.id} item={item} {...props} />
|
||||
<Comment depth={1} key={item.id} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} />
|
||||
))}
|
||||
{ncomments > FULL_COMMENTS_THRESHOLD &&
|
||||
<MoreFooter
|
||||
|
@ -125,19 +125,21 @@ const Embed = memo(function Embed ({ src, provider, id, meta, className, topLeve
|
||||
// This Twitter embed could use similar logic to the video embeds below
|
||||
if (provider === 'twitter') {
|
||||
return (
|
||||
<div className={classNames(styles.twitterContainer, !show && styles.twitterContained, className)}>
|
||||
<TwitterTweetEmbed
|
||||
tweetId={id}
|
||||
options={{ theme: darkMode ? 'dark' : 'light', width: topLevel ? '550px' : '350px' }}
|
||||
key={darkMode ? '1' : '2'}
|
||||
placeholder={<TweetSkeleton className={className} />}
|
||||
onLoad={() => setOverflowing(true)}
|
||||
/>
|
||||
{overflowing && !show &&
|
||||
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
|
||||
show full tweet
|
||||
</Button>}
|
||||
</div>
|
||||
<>
|
||||
<div className={classNames(styles.twitterContainer, !show && styles.twitterContained, className)}>
|
||||
<TwitterTweetEmbed
|
||||
tweetId={id}
|
||||
options={{ theme: darkMode ? 'dark' : 'light', width: topLevel ? '550px' : '350px' }}
|
||||
key={darkMode ? '1' : '2'}
|
||||
placeholder={<TweetSkeleton className={className} />}
|
||||
onLoad={() => setOverflowing(true)}
|
||||
/>
|
||||
{overflowing && !show &&
|
||||
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
|
||||
show full tweet
|
||||
</Button>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,6 @@ import { StaticLayout } from './layout'
|
||||
import styles from '@/styles/error.module.css'
|
||||
import Image from 'react-bootstrap/Image'
|
||||
import copy from 'clipboard-copy'
|
||||
import { LoggerContext } from './logger'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
import { useToast } from './toast'
|
||||
import { decodeMinifiedStackTrace } from '@/lib/stacktrace'
|
||||
@ -36,8 +35,6 @@ class ErrorBoundary extends Component {
|
||||
// You can use your own error logging service here
|
||||
console.log({ error, errorInfo })
|
||||
this.setState({ errorInfo })
|
||||
const logger = this.context
|
||||
logger?.error(this.getErrorDetails())
|
||||
}
|
||||
|
||||
render () {
|
||||
@ -47,7 +44,7 @@ class ErrorBoundary extends Component {
|
||||
const errorDetails = this.getErrorDetails()
|
||||
return (
|
||||
<StaticLayout footer={false}>
|
||||
<Image width='500' height='375' className='rounded-1 shadow-sm' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.gif`} fluid />
|
||||
<Image width='500' height='375' className='rounded-1 shadow-sm' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.webp`} fluid />
|
||||
<h1 className={styles.status} style={{ fontSize: '48px' }}>something went wrong</h1>
|
||||
{this.state.error && <CopyErrorButton errorDetails={errorDetails} />}
|
||||
</StaticLayout>
|
||||
@ -59,8 +56,6 @@ class ErrorBoundary extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
ErrorBoundary.contextType = LoggerContext
|
||||
|
||||
export default ErrorBoundary
|
||||
|
||||
// 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 Amboss from '@/svgs/amboss.svg'
|
||||
import Mempool from '@/svgs/bimi.svg'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Rewards from './footer-rewards'
|
||||
import useDarkMode from './dark-mode'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import { useAnimationEnabled } from '@/components/animation'
|
||||
|
||||
const RssPopover = (
|
||||
<Popover>
|
||||
@ -53,33 +53,43 @@ const RssPopover = (
|
||||
const SocialsPopover = (
|
||||
<Popover>
|
||||
<Popover.Body style={{ fontWeight: 500, fontSize: '.9rem' }}>
|
||||
<a
|
||||
href='https://njump.me/npub1jfujw6llhq7wuvu5detycdsq5v5yqf56sgrdq8wlgrryx2a2p09svwm0gx' className='nav-link p-0 d-inline-flex'
|
||||
target='_blank' rel='noreferrer'
|
||||
>
|
||||
nostr
|
||||
</a>
|
||||
<span className='mx-2 text-muted'> \ </span>
|
||||
<a
|
||||
href='https://twitter.com/stacker_news' className='nav-link p-0 d-inline-flex'
|
||||
target='_blank' rel='noreferrer'
|
||||
>
|
||||
twitter
|
||||
</a>
|
||||
<span className='mx-2 text-muted'> \ </span>
|
||||
<a
|
||||
href='https://www.youtube.com/@stackernews' className='nav-link p-0 d-inline-flex'
|
||||
target='_blank' rel='noreferrer'
|
||||
>
|
||||
youtube
|
||||
</a>
|
||||
<span className='mx-2 text-muted'> \ </span>
|
||||
<a
|
||||
href='https://www.fountain.fm/show/Mg1AWuvkeZSFhsJZ3BW2' className='nav-link p-0 d-inline-flex'
|
||||
target='_blank' rel='noreferrer'
|
||||
>
|
||||
pod
|
||||
</a>
|
||||
<div className='d-flex justify-content-center'>
|
||||
<a
|
||||
href='https://njump.me/npub1jfujw6llhq7wuvu5detycdsq5v5yqf56sgrdq8wlgrryx2a2p09svwm0gx' className='nav-link p-0 d-inline-flex'
|
||||
target='_blank' rel='noreferrer'
|
||||
>
|
||||
nostr
|
||||
</a>
|
||||
<span className='mx-2 text-muted'> \ </span>
|
||||
<a
|
||||
href='https://twitter.com/stacker_news' className='nav-link p-0 d-inline-flex'
|
||||
target='_blank' rel='noreferrer'
|
||||
>
|
||||
twitter
|
||||
</a>
|
||||
<span className='mx-2 text-muted'> \ </span>
|
||||
<a
|
||||
href='https://www.youtube.com/@stackernews' className='nav-link p-0 d-inline-flex'
|
||||
target='_blank' rel='noreferrer'
|
||||
>
|
||||
youtube
|
||||
</a>
|
||||
</div>
|
||||
<div className='d-flex justify-content-center'>
|
||||
<a
|
||||
href='https://www.fountain.fm/show/Mg1AWuvkeZSFhsJZ3BW2' className='nav-link p-0 d-inline-flex'
|
||||
target='_blank' rel='noreferrer'
|
||||
>
|
||||
pod
|
||||
</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>
|
||||
)
|
||||
@ -135,24 +145,10 @@ const LegalPopover = (
|
||||
export default function Footer ({ links = true }) {
|
||||
const [darkMode, darkModeToggle] = useDarkMode()
|
||||
|
||||
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 [animationEnabled, toggleAnimation] = useAnimationEnabled()
|
||||
|
||||
const DarkModeIcon = darkMode ? Sun : Moon
|
||||
const LnIcon = lightning === 'yes' ? No : Bolt
|
||||
const LnIcon = animationEnabled ? No : Bolt
|
||||
|
||||
const version = process.env.NEXT_PUBLIC_COMMIT_HASH
|
||||
|
||||
@ -165,8 +161,8 @@ export default function Footer ({ links = true }) {
|
||||
<ActionTooltip notForm overlayText={`${darkMode ? 'disable' : 'enable'} dark mode`}>
|
||||
<DarkModeIcon onClick={darkModeToggle} width={20} height={20} className='fill-grey theme' suppressHydrationWarning />
|
||||
</ActionTooltip>
|
||||
<ActionTooltip notForm overlayText={`${lightning === 'yes' ? 'disable' : 'enable'} lightning animations`}>
|
||||
<LnIcon onClick={toggleLightning} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
|
||||
<ActionTooltip notForm overlayText={`${animationEnabled ? 'disable' : 'enable'} lightning animations`}>
|
||||
<LnIcon onClick={toggleAnimation} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
|
||||
</ActionTooltip>
|
||||
</div>
|
||||
<div className='mb-0' style={{ fontWeight: 500 }}>
|
||||
|
@ -16,6 +16,7 @@ import AddIcon from '@/svgs/add-fill.svg'
|
||||
import CloseIcon from '@/svgs/close-line.svg'
|
||||
import { gql, useLazyQuery } from '@apollo/client'
|
||||
import { USER_SUGGESTIONS } from '@/fragments/users'
|
||||
import { SUB_SUGGESTIONS } from '@/fragments/subs'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import { useToast } from './toast'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
@ -33,12 +34,9 @@ import Info from './info'
|
||||
import { useMe } from './me'
|
||||
import classNames from 'classnames'
|
||||
import Clipboard from '@/svgs/clipboard-line.svg'
|
||||
import QrIcon from '@/svgs/qr-code-line.svg'
|
||||
import QrScanIcon from '@/svgs/qr-scan-line.svg'
|
||||
import { useShowModal } from './modal'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { qrImageSettings } from './qr'
|
||||
import { useIsClient } from './use-client'
|
||||
import PageLoading from './page-loading'
|
||||
|
||||
@ -77,7 +75,7 @@ export function SubmitButton ({
|
||||
)
|
||||
}
|
||||
|
||||
function CopyButton ({ value, icon, ...props }) {
|
||||
export function CopyButton ({ value, icon, ...props }) {
|
||||
const toaster = useToast()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@ -139,6 +137,174 @@ function setNativeValue (textarea, 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 }) {
|
||||
const [tab, setTab] = useState('write')
|
||||
const [, meta, helpers] = useField(props)
|
||||
@ -151,10 +317,8 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||
const [updateUploadFees] = useLazyQuery(gql`
|
||||
query uploadFees($s3Keys: [Int]!) {
|
||||
uploadFees(s3Keys: $s3Keys) {
|
||||
totalFees
|
||||
nUnpaid
|
||||
uploadFees
|
||||
bytes24h
|
||||
}
|
||||
}`, {
|
||||
fetchPolicy: 'no-cache',
|
||||
@ -163,13 +327,15 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||
console.error(err)
|
||||
},
|
||||
onCompleted: ({ uploadFees }) => {
|
||||
const { uploadFees: feePerUpload, nUnpaid } = uploadFees
|
||||
const totalFees = feePerUpload * nUnpaid
|
||||
merge({
|
||||
uploadFees: {
|
||||
term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`,
|
||||
term: `+ ${numWithUnits(feePerUpload, { abbreviate: false })} x ${nUnpaid}`,
|
||||
label: 'upload fee',
|
||||
op: '+',
|
||||
modifier: cost => cost + uploadFees.totalFees,
|
||||
omit: !uploadFees.totalFees
|
||||
modifier: cost => cost + totalFees,
|
||||
omit: !totalFees
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -198,18 +364,12 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||
}
|
||||
}, [innerRef, selectionRange.start, selectionRange.end])
|
||||
|
||||
const [mention, setMention] = useState()
|
||||
const insertMention = useCallback((name) => {
|
||||
if (mention?.start === undefined || mention?.end === undefined) return
|
||||
const { start, end } = mention
|
||||
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 { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({
|
||||
meta,
|
||||
helpers,
|
||||
innerRef,
|
||||
setSelectionRange
|
||||
})
|
||||
|
||||
const uploadFeesUpdate = useDebounceCallback(
|
||||
(text) => {
|
||||
@ -219,86 +379,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||
|
||||
const onChangeInner = useCallback((formik, e) => {
|
||||
if (onChange) onChange(formik, e)
|
||||
// check for mention editing
|
||||
const { value, selectionStart } = e.target
|
||||
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])
|
||||
uploadFeesUpdate(e.target.value)
|
||||
handleTextChange(e)
|
||||
}, [onChange, uploadFeesUpdate, handleTextChange])
|
||||
|
||||
const onPaste = useCallback((event) => {
|
||||
const items = event.clipboardData.items
|
||||
@ -342,6 +425,44 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||
setDragStyle(null)
|
||||
}, [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 (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
|
||||
@ -408,24 +529,25 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||
</span>
|
||||
</Nav>
|
||||
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
|
||||
<UserSuggest
|
||||
query={mention?.query}
|
||||
onSelect={insertMention}
|
||||
dropdownStyle={mention?.style}
|
||||
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions }) => (
|
||||
<InputInner
|
||||
innerRef={innerRef}
|
||||
{...props}
|
||||
onChange={onChangeInner}
|
||||
onKeyDown={onKeyDownInner(userSuggestOnKeyDown)}
|
||||
onBlur={() => setTimeout(resetSuggestions, 500)}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onPaste={onPaste}
|
||||
className={dragStyle === 'over' ? styles.dragOver : ''}
|
||||
/>)}
|
||||
</UserSuggest>
|
||||
<DualAutocompleteWrapper
|
||||
userAutocomplete={userAutocomplete}
|
||||
territoryAutocomplete={territoryAutocomplete}
|
||||
>
|
||||
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => (
|
||||
<InputInner
|
||||
innerRef={innerRef}
|
||||
{...props}
|
||||
onChange={onChangeInner}
|
||||
onKeyDown={onKeyDownInner(userSuggestOnKeyDown, territorySuggestOnKeyDown)}
|
||||
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onPaste={onPaste}
|
||||
className={dragStyle === 'over' ? styles.dragOver : ''}
|
||||
/>
|
||||
)}
|
||||
</DualAutocompleteWrapper>
|
||||
</div>
|
||||
{tab !== 'write' &&
|
||||
<div className='form-group'>
|
||||
@ -487,7 +609,7 @@ function FormGroup ({ className, label, children }) {
|
||||
function InputInner ({
|
||||
prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue,
|
||||
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, hideError,
|
||||
...props
|
||||
AppendColumn, ...props
|
||||
}) {
|
||||
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
|
||||
const formik = noForm ? null : useFormikContext()
|
||||
@ -565,38 +687,43 @@ function InputInner ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputGroup hasValidation className={inputGroupClassName}>
|
||||
{prepend}
|
||||
<BootstrapForm.Control
|
||||
ref={innerRef}
|
||||
{...field}
|
||||
{...props}
|
||||
onKeyDown={onKeyDownInner}
|
||||
onChange={onChangeInner}
|
||||
onBlur={onBlurInner}
|
||||
isInvalid={!hideError && invalid} // if hideError is true, handle error showing separately
|
||||
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
|
||||
/>
|
||||
{(isClient && clear && field.value && !props.readOnly) &&
|
||||
<Button
|
||||
variant={null}
|
||||
onClick={(e) => {
|
||||
helpers.setValue('')
|
||||
if (storageKey) {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(formik, { target: { value: '' } })
|
||||
}
|
||||
}}
|
||||
className={`${styles.clearButton} ${styles.appendButton} ${invalid ? styles.isInvalid : ''}`}
|
||||
><CloseIcon className='fill-grey' height={20} width={20} />
|
||||
</Button>}
|
||||
{append}
|
||||
<BootstrapForm.Control.Feedback type='invalid'>
|
||||
{meta.touched && meta.error}
|
||||
</BootstrapForm.Control.Feedback>
|
||||
</InputGroup>
|
||||
<Row>
|
||||
<Col>
|
||||
<InputGroup hasValidation className={inputGroupClassName}>
|
||||
{prepend}
|
||||
<BootstrapForm.Control
|
||||
ref={innerRef}
|
||||
{...field}
|
||||
{...props}
|
||||
onKeyDown={onKeyDownInner}
|
||||
onChange={onChangeInner}
|
||||
onBlur={onBlurInner}
|
||||
isInvalid={!hideError && invalid} // if hideError is true, handle error showing separately
|
||||
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
|
||||
/>
|
||||
{(isClient && clear && field.value && !props.readOnly) &&
|
||||
<Button
|
||||
variant={null}
|
||||
onClick={(e) => {
|
||||
helpers.setValue('')
|
||||
if (storageKey) {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(formik, { target: { value: '' } })
|
||||
}
|
||||
}}
|
||||
className={`${styles.clearButton} ${styles.appendButton} ${invalid ? styles.isInvalid : ''}`}
|
||||
><CloseIcon className='fill-grey' height={20} width={20} />
|
||||
</Button>}
|
||||
{append}
|
||||
<BootstrapForm.Control.Feedback type='invalid'>
|
||||
{meta.touched && meta.error}
|
||||
</BootstrapForm.Control.Feedback>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
{AppendColumn && <AppendColumn className={meta.touched && meta.error ? 'invisible' : ''} />}
|
||||
</Row>
|
||||
{hint && (
|
||||
<BootstrapForm.Text>
|
||||
{hint}
|
||||
@ -617,34 +744,34 @@ function InputInner ({
|
||||
}
|
||||
|
||||
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
|
||||
export function UserSuggest ({
|
||||
query, onSelect, dropdownStyle, children,
|
||||
transformUser = user => user, selectWithTab = true, filterUsers = () => true
|
||||
|
||||
export function BaseSuggest ({
|
||||
query, onSelect, dropdownStyle,
|
||||
transformItem = item => item, selectWithTab = true, filterItems = () => true,
|
||||
getSuggestionsQuery, queryName, itemsField,
|
||||
children
|
||||
}) {
|
||||
const [getSuggestions] = useLazyQuery(USER_SUGGESTIONS, {
|
||||
const [getSuggestions] = useLazyQuery(getSuggestionsQuery, {
|
||||
onCompleted: data => {
|
||||
query !== undefined && setSuggestions({
|
||||
array: data.userSuggestions
|
||||
.filter((...args) => filterUsers(query, ...args))
|
||||
.map(transformUser),
|
||||
array: data[itemsField]
|
||||
.filter((...args) => filterItems(query, ...args))
|
||||
.map(transformItem),
|
||||
index: 0
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
|
||||
const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (query !== undefined) {
|
||||
// remove both the leading @ and any @domain after nym
|
||||
const q = query?.replace(/^[@ ]+|[ ]+$/g, '').replace(/@[^\s]*$/, '')
|
||||
// remove the leading character and any trailing spaces
|
||||
const q = query?.replace(/^[@ ~]+|[ ]+$/g, '').replace(/@[^\s]*$/, '').replace(/~[^\s]*$/, '')
|
||||
getSuggestions({ variables: { q, limit: 5 } })
|
||||
} else {
|
||||
resetSuggestions()
|
||||
}
|
||||
}, [query, resetSuggestions, getSuggestions])
|
||||
|
||||
const onKeyDown = useCallback(e => {
|
||||
switch (e.code) {
|
||||
case 'ArrowUp':
|
||||
@ -689,7 +816,6 @@ export function UserSuggest ({
|
||||
break
|
||||
}
|
||||
}, [onSelect, resetSuggestions, suggestions])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children?.({ onKeyDown, resetSuggestions })}
|
||||
@ -712,17 +838,17 @@ export function UserSuggest ({
|
||||
)
|
||||
}
|
||||
|
||||
export function InputUserSuggest ({
|
||||
label, groupClassName, transformUser, filterUsers,
|
||||
selectWithTab, onChange, transformQuery, ...props
|
||||
function BaseInputSuggest ({
|
||||
label, groupClassName, transformItem, filterItems,
|
||||
selectWithTab, onChange, transformQuery, SuggestComponent, prefixRegex, ...props
|
||||
}) {
|
||||
const [ovalue, setOValue] = useState()
|
||||
const [query, setQuery] = useState()
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
<UserSuggest
|
||||
transformUser={transformUser}
|
||||
filterUsers={filterUsers}
|
||||
<SuggestComponent
|
||||
transformItem={transformItem}
|
||||
filterItems={filterItems}
|
||||
selectWithTab={selectWithTab}
|
||||
onSelect={(v) => {
|
||||
// HACK ... ovalue does not trigger onChange
|
||||
@ -737,19 +863,85 @@ export function InputUserSuggest ({
|
||||
autoComplete='off'
|
||||
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)
|
||||
setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, ''))
|
||||
setQuery(e.target.value.replace(prefixRegex, ''))
|
||||
}}
|
||||
overrideValue={ovalue}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => setTimeout(resetSuggestions, 500)}
|
||||
/>
|
||||
)}
|
||||
</UserSuggest>
|
||||
</SuggestComponent>
|
||||
</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 }) {
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
@ -765,31 +957,38 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
|
||||
<FieldArray name={name} hasValidation>
|
||||
{({ form, ...fieldArrayHelpers }) => {
|
||||
const options = form.values[name]
|
||||
|
||||
return (
|
||||
<>
|
||||
{options?.map((_, i) => (
|
||||
<div key={i}>
|
||||
<Row className='mb-2'>
|
||||
<Col>
|
||||
{children
|
||||
? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined })
|
||||
: <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>
|
||||
{options.length - 1 === i &&
|
||||
<>
|
||||
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
|
||||
{form.touched[name] && typeof form.errors[name] === 'string' &&
|
||||
<div className='invalid-feedback d-block'>{form.errors[name]}</div>}
|
||||
</>}
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
{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}>
|
||||
<Row className='mb-2'>
|
||||
<Col>
|
||||
{children
|
||||
? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined, AppendColumn })
|
||||
: <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} AppendColumn={AppendColumn} />}
|
||||
</Col>
|
||||
|
||||
{options.length - 1 === i &&
|
||||
<>
|
||||
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
|
||||
{form.touched[name] && typeof form.errors[name] === 'string' &&
|
||||
<div className='invalid-feedback d-block'>{form.errors[name]}</div>}
|
||||
</>}
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
@ -878,15 +1077,14 @@ export function Form ({
|
||||
})
|
||||
}, [storageKeyPrefix])
|
||||
|
||||
const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => {
|
||||
const variables = { amount, ...values }
|
||||
const onSubmitInner = useCallback(async (values, ...args) => {
|
||||
if (requireSession && !me) {
|
||||
throw new SessionRequiredError()
|
||||
}
|
||||
|
||||
try {
|
||||
if (onSubmit) {
|
||||
await onSubmit(variables, ...args)
|
||||
await onSubmit(values, ...args)
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err.message, err)
|
||||
@ -1144,33 +1342,6 @@ 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 }) {
|
||||
const showModal = useShowModal()
|
||||
const toaster = useToast()
|
||||
@ -1191,8 +1362,10 @@ function PasswordScanner ({ onScan, text }) {
|
||||
<Scanner
|
||||
formats={['qr_code']}
|
||||
onScan={([{ rawValue: result }]) => {
|
||||
onScan(result)
|
||||
onClose()
|
||||
if (result) {
|
||||
onScan(result)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
styles={{
|
||||
video: {
|
||||
@ -1207,6 +1380,7 @@ function PasswordScanner ({ onScan, text }) {
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
components={{ audio: false }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -1233,12 +1407,12 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, value: ini
|
||||
{copy && (
|
||||
<CopyButton icon value={field?.value} />
|
||||
)}
|
||||
{qr && (readOnly
|
||||
? <QrPassword value={field?.value} />
|
||||
: <PasswordScanner
|
||||
text="Where'd you learn to square dance?"
|
||||
onScan={v => helpers.setValue(v)}
|
||||
/>)}
|
||||
{qr && (
|
||||
<PasswordScanner
|
||||
text="Where'd you learn to square dance?"
|
||||
onScan={v => helpers.setValue(v)}
|
||||
/>
|
||||
)}
|
||||
{append}
|
||||
</>
|
||||
)
|
||||
|
@ -109,4 +109,4 @@
|
||||
padding-top: 1px;
|
||||
background-color: var(--bs-body-bg);
|
||||
z-index: 1000;
|
||||
}
|
||||
}
|
||||
|
@ -13,12 +13,12 @@ export default function CCInfo (props) {
|
||||
<ul>
|
||||
<li>if the zap is small and you don't have a direct channel to SN, the routing fee may exceed SN's 3% max fee</li>
|
||||
<li>check your <Link href='/wallets/logs'>wallet logs</Link> for clues</li>
|
||||
<li>if you have questions about the errors in your wallet logs, mention the error in the <Link href='/daily'>saloon</Link></li>
|
||||
<li>if you have questions about the errors in your wallet logs, mention the error in the <Link href='/api/daily'>saloon</Link></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>some zaps might be smaller than your configured receiving dust limit
|
||||
<ul>
|
||||
<li>you can configure your dust limit in your <Link href='/settings'>settings</Link></li>
|
||||
<li>you can configure your dust limit in your <Link href='/wallets/settings'>wallet settings</Link></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -6,9 +6,9 @@ import { CompactLongCountdown } from './countdown'
|
||||
import PayerData from './payer-data'
|
||||
import Bolt11Info from './bolt11-info'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { INVOICE } from '@/fragments/wallet'
|
||||
import { INVOICE } from '@/fragments/invoice'
|
||||
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||
import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/errors'
|
||||
import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/client/errors'
|
||||
import ItemJob from './item-job'
|
||||
import Item from './item'
|
||||
import { CommentFlat } from './comment'
|
||||
|
@ -6,19 +6,22 @@ import { useMe } from './me'
|
||||
import UpBolt from '@/svgs/bolt.svg'
|
||||
import { amountSchema, boostSchema } from '@/lib/validate'
|
||||
import { useToast } from './toast'
|
||||
import { useLightning } from './lightning'
|
||||
import { nextTip, defaultTipIncludingRandom } from './upvote'
|
||||
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
|
||||
import { usePaidMutation } from './use-paid-mutation'
|
||||
import { ACT_MUTATION } from '@/fragments/paidAction'
|
||||
import { meAnonSats } from '@/lib/apollo'
|
||||
import { BoostItemInput } from './adv-post-form'
|
||||
import { useSendWallets } from '@/wallets/index'
|
||||
import { useHasSendWallet } from '@/wallets/client/hooks'
|
||||
import { useAnimation } from '@/components/animation'
|
||||
|
||||
const defaultTips = [100, 1000, 10_000, 100_000]
|
||||
|
||||
const Tips = ({ setOValue }) => {
|
||||
const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
|
||||
const customTips = getCustomTips()
|
||||
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) =>
|
||||
<Button
|
||||
size='sm'
|
||||
@ -37,11 +40,7 @@ const Tips = ({ setOValue }) => {
|
||||
const getCustomTips = () => JSON.parse(window.localStorage.getItem('custom-tips')) || []
|
||||
|
||||
const addCustomTip = (amount) => {
|
||||
if (defaultTips.includes(amount)) return
|
||||
let customTips = Array.from(new Set([amount, ...getCustomTips()]))
|
||||
if (customTips.length > 3) {
|
||||
customTips = customTips.slice(0, 3)
|
||||
}
|
||||
const customTips = Array.from(new Set([amount, ...getCustomTips()])).slice(0, 7)
|
||||
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
|
||||
}
|
||||
|
||||
@ -89,7 +88,7 @@ function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'B
|
||||
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
|
||||
const inputRef = useRef(null)
|
||||
const { me } = useMe()
|
||||
const wallets = useSendWallets()
|
||||
const hasSendWallet = useHasSendWallet()
|
||||
const [oValue, setOValue] = useState()
|
||||
|
||||
useEffect(() => {
|
||||
@ -97,7 +96,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
||||
}, [onClose, item.id])
|
||||
|
||||
const actor = useAct()
|
||||
const strike = useLightning()
|
||||
const animate = useAnimation()
|
||||
|
||||
const onSubmit = useCallback(async ({ amount }) => {
|
||||
if (abortSignal && zapUndoTrigger({ me, amount })) {
|
||||
@ -112,12 +111,12 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
||||
}
|
||||
|
||||
const onPaid = () => {
|
||||
strike()
|
||||
animate()
|
||||
onClose?.()
|
||||
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
||||
}
|
||||
|
||||
const closeImmediately = wallets.length > 0 || me?.privates?.sats > Number(amount)
|
||||
const closeImmediately = hasSendWallet || me?.privates?.sats > Number(amount)
|
||||
if (closeImmediately) {
|
||||
onPaid()
|
||||
}
|
||||
@ -127,7 +126,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
||||
id: item.id,
|
||||
sats: Number(amount),
|
||||
act,
|
||||
hasSendWallet: wallets.length > 0
|
||||
hasSendWallet
|
||||
},
|
||||
optimisticResponse: me
|
||||
? {
|
||||
@ -144,7 +143,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
||||
})
|
||||
if (error) throw error
|
||||
addCustomTip(Number(amount))
|
||||
}, [me, actor, wallets.length, act, item.id, onClose, abortSignal, strike])
|
||||
}, [me, actor, hasSendWallet, act, item.id, onClose, abortSignal, animate])
|
||||
|
||||
return act === 'BOOST'
|
||||
? <BoostForm step={step} onSubmit={onSubmit} item={item} inputRef={inputRef} act={act}>{children}</BoostForm>
|
||||
@ -264,13 +263,13 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
|
||||
// because the mutation name we use varies,
|
||||
// we need to extract the result/invoice from the response
|
||||
const getPaidActionResult = data => Object.values(data)[0]
|
||||
const wallets = useSendWallets()
|
||||
const hasSendWallet = useHasSendWallet()
|
||||
|
||||
const [act] = usePaidMutation(query, {
|
||||
waitFor: inv =>
|
||||
// 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)
|
||||
wallets.length > 0
|
||||
hasSendWallet
|
||||
? inv?.actionState === 'PAID'
|
||||
: inv?.satsReceived > 0,
|
||||
...options,
|
||||
@ -299,9 +298,9 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
|
||||
}
|
||||
|
||||
export function useZap () {
|
||||
const wallets = useSendWallets()
|
||||
const hasSendWallet = useHasSendWallet()
|
||||
const act = useAct()
|
||||
const strike = useLightning()
|
||||
const animate = useAnimation()
|
||||
const toaster = useToast()
|
||||
|
||||
return useCallback(async ({ item, me, abortSignal }) => {
|
||||
@ -310,14 +309,14 @@ export function useZap () {
|
||||
// add current sats to next tip since idempotent zaps use desired total zap not difference
|
||||
const sats = nextTip(meSats, { ...me?.privates })
|
||||
|
||||
const variables = { id: item.id, sats, act: 'TIP', hasSendWallet: wallets.length > 0 }
|
||||
const variables = { id: item.id, sats, act: 'TIP', hasSendWallet }
|
||||
const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } }
|
||||
|
||||
try {
|
||||
await abortSignal.pause({ me, amount: sats })
|
||||
strike()
|
||||
animate()
|
||||
// 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: wallets.length > 0 || me?.privates?.sats > sats } })
|
||||
const { error } = await act({ variables, optimisticResponse, context: { batch: hasSendWallet || me?.privates?.sats > sats } })
|
||||
if (error) throw error
|
||||
} catch (error) {
|
||||
if (error instanceof ActCanceledError) {
|
||||
@ -328,7 +327,7 @@ export function useZap () {
|
||||
// but right now this toast is noisy for optimistic zaps
|
||||
console.error(error)
|
||||
}
|
||||
}, [act, toaster, strike, wallets.length])
|
||||
}, [act, toaster, animate, hasSendWallet])
|
||||
}
|
||||
|
||||
export class ActCanceledError extends Error {
|
||||
|
@ -191,6 +191,8 @@ export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props
|
||||
comments={item.comments.comments}
|
||||
commentsCursor={item.comments.cursor}
|
||||
fetchMoreComments={fetchMoreComments}
|
||||
lastCommentAt={item.lastCommentAt}
|
||||
item={item}
|
||||
/>
|
||||
</div>}
|
||||
</CarouselProvider>
|
||||
|
@ -89,11 +89,14 @@ export default function ItemInfo ({
|
||||
const myPost = (me && root && Number(me.id) === Number(root.user.id))
|
||||
const rootReply = item.path.split('.').length === 2
|
||||
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
|
||||
|
||||
return (
|
||||
<div className={className || `${styles.other}`}>
|
||||
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
|
||||
{!isPinnedPost && !(isPinnedSubReply && !full) && !isAd &&
|
||||
<>
|
||||
<span title={itemTitle(item)}>
|
||||
{numWithUnits(item.sats)}
|
||||
@ -107,7 +110,7 @@ export default function ItemInfo ({
|
||||
</>}
|
||||
<Link
|
||||
href={`/items/${item.id}`} onClick={(e) => {
|
||||
const viewedAt = commentsViewedAt(item)
|
||||
const viewedAt = commentsViewedAt(item.id)
|
||||
if (viewedAt) {
|
||||
e.preventDefault()
|
||||
router.push(
|
||||
@ -282,7 +285,7 @@ function InfoDropdownItem ({ item }) {
|
||||
)
|
||||
}
|
||||
|
||||
function PaymentInfo ({ item, disableRetry, setDisableRetry }) {
|
||||
export function PaymentInfo ({ item, disableRetry, setDisableRetry }) {
|
||||
const { me } = useMe()
|
||||
const toaster = useToast()
|
||||
const retryCreateItem = useRetryCreateItem({ id: item.id })
|
||||
|
@ -13,8 +13,9 @@ import { MEDIA_URL } from '@/lib/constants'
|
||||
import { abbrNum } from '@/lib/format'
|
||||
import { Badge } from 'react-bootstrap'
|
||||
import SubPopover from './sub-popover'
|
||||
import { PaymentInfo } from './item-info'
|
||||
|
||||
export default function ItemJob ({ item, toc, rank, children }) {
|
||||
export default function ItemJob ({ item, toc, rank, children, disableRetry, setDisableRetry }) {
|
||||
const isEmail = string().email().isValidSync(item.url)
|
||||
|
||||
return (
|
||||
@ -78,6 +79,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
||||
<Link href={`/items/${item.id}/edit`} className='text-reset fw-bold'>
|
||||
edit
|
||||
</Link>
|
||||
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -29,7 +29,7 @@ import { useShowModal } from './modal'
|
||||
import { BoostHelp } from './adv-post-form'
|
||||
|
||||
function onItemClick (e, router, item) {
|
||||
const viewedAt = commentsViewedAt(item)
|
||||
const viewedAt = commentsViewedAt(item.id)
|
||||
if (viewedAt) {
|
||||
e.preventDefault()
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
|
@ -45,18 +45,18 @@ function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LightningExplainer ({ text, children }) {
|
||||
function LightningExplainer ({ text, children, backButton, md = 12, lg = 6 }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<Container>
|
||||
<div className={styles.login}>
|
||||
<div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>
|
||||
{backButton && <div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>}
|
||||
<h3 className='w-100 pb-2'>
|
||||
{text || 'Login'} with Lightning
|
||||
</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>
|
||||
<Row className='w-100 text-muted'>
|
||||
<Col className='ps-0 mb-4' md>
|
||||
<Col className='ps-0 mb-4' md={md} lg={lg}>
|
||||
<AccordianItem
|
||||
header={`Which wallets can I use to ${(text || 'Login').toLowerCase()}?`}
|
||||
body={
|
||||
@ -92,7 +92,7 @@ function LightningExplainer ({ text, children }) {
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col md className='mx-auto' style={{ maxWidth: '300px' }}>
|
||||
<Col md={md} lg={lg} className='mx-auto' style={{ maxWidth: '300px' }}>
|
||||
{children}
|
||||
</Col>
|
||||
</Row>
|
||||
@ -101,9 +101,9 @@ function LightningExplainer ({ text, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function LightningAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
|
||||
export function LightningAuthWithExplainer ({ text, callbackUrl, multiAuth, backButton = true, md = 12, lg = 6 }) {
|
||||
return (
|
||||
<LightningExplainer text={text}>
|
||||
<LightningExplainer text={text} backButton={backButton} md={md} lg={lg}>
|
||||
<LightningAuth callbackUrl={callbackUrl} multiAuth={multiAuth} />
|
||||
</LightningExplainer>
|
||||
)
|
||||
|
@ -35,3 +35,12 @@
|
||||
.linkBoxParent img {
|
||||
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;
|
||||
}
|
||||
|
@ -1,62 +0,0 @@
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,119 +0,0 @@
|
||||
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,6 +12,7 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
||||
import { datePivot } from '@/lib/time'
|
||||
import * as cookie from 'cookie'
|
||||
import { cookieOptions } from '@/lib/auth'
|
||||
import Link from 'next/link'
|
||||
|
||||
export function EmailLoginForm ({ text, callbackUrl, multiAuth }) {
|
||||
const disabled = multiAuth
|
||||
@ -52,12 +53,26 @@ const authErrorMessages = {
|
||||
default: 'Auth failed. Try again or choose a different method.'
|
||||
}
|
||||
|
||||
export function authErrorMessage (error) {
|
||||
return error && (authErrorMessages[error] ?? authErrorMessages.default)
|
||||
export function authErrorMessage (error, signin) {
|
||||
if (!error) return null
|
||||
|
||||
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 }) {
|
||||
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error))
|
||||
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error, signin))
|
||||
const router = useRouter()
|
||||
|
||||
// signup/signin awareness cookie
|
||||
|
@ -4,6 +4,12 @@ import BackArrow from '@/svgs/arrow-left-line.svg'
|
||||
import { useRouter } from 'next/router'
|
||||
import ActionDropdown from './action-dropdown'
|
||||
|
||||
export class ModalClosedError extends Error {
|
||||
constructor () {
|
||||
super('modal closed')
|
||||
}
|
||||
}
|
||||
|
||||
export const ShowModalContext = createContext(() => null)
|
||||
|
||||
export function ShowModalProvider ({ children }) {
|
||||
|
@ -7,24 +7,24 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import Price from '../price'
|
||||
import SubSelect from '../sub-select'
|
||||
import { USER_ID } from '../../lib/constants'
|
||||
import Head from 'next/head'
|
||||
import NoteIcon from '../../svgs/notification-4-fill.svg'
|
||||
import { useMe } from '../me'
|
||||
import { abbrNum } from '../../lib/format'
|
||||
import { useServiceWorker } from '../serviceworker'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import Badges from '../badge'
|
||||
import { randInRange } from '../../lib/rand'
|
||||
import { useLightning } from '../lightning'
|
||||
import LightningIcon from '../../svgs/bolt.svg'
|
||||
import SearchIcon from '../../svgs/search-line.svg'
|
||||
import classNames from 'classnames'
|
||||
import SnIcon from '@/svgs/sn.svg'
|
||||
import { useHasNewNotes } from '../use-has-new-notes'
|
||||
import { useWallets } from '@/wallets/index'
|
||||
import SwitchAccountList, { useAccounts } from '@/components/account'
|
||||
// import { useWallets } from '@/wallets/client/hooks'
|
||||
import { useWalletIndicator } from '@/wallets/client/hooks'
|
||||
import SwitchAccountList, { nextAccount, useAccounts } from '@/components/account'
|
||||
import { useShowModal } from '@/components/modal'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
import Head from 'next/head'
|
||||
|
||||
export function Brand ({ className }) {
|
||||
return (
|
||||
<Link href='/' passHref legacyBehavior>
|
||||
@ -164,8 +164,34 @@ 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 }) {
|
||||
if (!me) return null
|
||||
|
||||
const profileIndicator = !me.bioId
|
||||
const walletIndicator = useWalletIndicator()
|
||||
const indicator = profileIndicator || walletIndicator
|
||||
|
||||
return (
|
||||
<div className=''>
|
||||
<Dropdown className={styles.dropdown} align='end'>
|
||||
@ -173,12 +199,7 @@ export function MeDropdown ({ me, dropNavKey }) {
|
||||
<div className='d-flex align-items-center'>
|
||||
<Nav.Link eventKey={me.name} as='span' className='p-0 position-relative'>
|
||||
{`@${me.name}`}
|
||||
{!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>}
|
||||
{indicator && <Indicator superscript />}
|
||||
</Nav.Link>
|
||||
<Badges user={me} />
|
||||
</div>
|
||||
@ -187,17 +208,17 @@ export function MeDropdown ({ me, dropNavKey }) {
|
||||
<Link href={'/' + me.name} passHref legacyBehavior>
|
||||
<Dropdown.Item active={me.name === dropNavKey}>
|
||||
profile
|
||||
{me && !me.bioId &&
|
||||
<div className='p-1 d-inline-block bg-secondary ms-1'>
|
||||
<span className='invisible'>{' '}</span>
|
||||
</div>}
|
||||
{profileIndicator && <Indicator />}
|
||||
</Dropdown.Item>
|
||||
</Link>
|
||||
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
|
||||
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
|
||||
</Link>
|
||||
<Link href='/wallets' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
|
||||
<Dropdown.Item eventKey='wallets'>
|
||||
wallets
|
||||
{walletIndicator && <Indicator />}
|
||||
</Dropdown.Item>
|
||||
</Link>
|
||||
<Link href='/credits' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
|
||||
@ -272,8 +293,7 @@ export default function LoginButton () {
|
||||
|
||||
function LogoutObstacle ({ onClose }) {
|
||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||
const { removeLocalWallets } = useWallets()
|
||||
const { nextAccount } = useAccounts()
|
||||
// const { removeLocalWallets } = useWallets()
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
@ -304,8 +324,6 @@ function LogoutObstacle ({ onClose }) {
|
||||
await togglePushSubscription().catch(console.error)
|
||||
}
|
||||
|
||||
removeLocalWallets()
|
||||
|
||||
await signOut({ callbackUrl: '/' })
|
||||
}}
|
||||
>
|
||||
@ -340,7 +358,7 @@ export function LogoutDropdownItem ({ handleClose }) {
|
||||
|
||||
function SwitchAccountButton ({ handleClose }) {
|
||||
const showModal = useShowModal()
|
||||
const { accounts } = useAccounts()
|
||||
const accounts = useAccounts()
|
||||
|
||||
if (accounts.length === 0) return null
|
||||
|
||||
@ -378,18 +396,6 @@ export function LoginButtons ({ handleClose }) {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className='position-relative'>
|
||||
<Dropdown className={styles.dropdown} align='end' autoClose>
|
||||
|
@ -2,11 +2,12 @@ import { useState } from 'react'
|
||||
import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap'
|
||||
import { MEDIA_URL } from '@/lib/constants'
|
||||
import Link from 'next/link'
|
||||
import { LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
|
||||
import { Indicator, LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
|
||||
import AnonIcon from '@/svgs/spy-fill.svg'
|
||||
import styles from './footer.module.css'
|
||||
import canvasStyles from './offcanvas.module.css'
|
||||
import classNames from 'classnames'
|
||||
import { useWalletIndicator } from '@/wallets/client/hooks'
|
||||
|
||||
export default function OffCanvas ({ me, dropNavKey }) {
|
||||
const [show, setShow] = useState(false)
|
||||
@ -25,6 +26,9 @@ export default function OffCanvas ({ me, dropNavKey }) {
|
||||
)
|
||||
: <span className='text-muted pointer'><AnonIcon onClick={onClick} width='22' height='22' /></span>
|
||||
|
||||
const profileIndicator = me && !me.bioId
|
||||
const walletIndicator = useWalletIndicator()
|
||||
|
||||
return (
|
||||
<>
|
||||
<MeImage onClick={handleShow} />
|
||||
@ -50,17 +54,17 @@ export default function OffCanvas ({ me, dropNavKey }) {
|
||||
<Link href={'/' + me.name} passHref legacyBehavior>
|
||||
<Dropdown.Item active={me.name === dropNavKey}>
|
||||
profile
|
||||
{me && !me.bioId &&
|
||||
<div className='p-1 d-inline-block bg-secondary ms-1'>
|
||||
<span className='invisible'>{' '}</span>
|
||||
</div>}
|
||||
{profileIndicator && <Indicator />}
|
||||
</Dropdown.Item>
|
||||
</Link>
|
||||
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
|
||||
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
|
||||
</Link>
|
||||
<Link href='/wallets' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
|
||||
<Dropdown.Item eventKey='wallets'>
|
||||
wallets
|
||||
{walletIndicator && <Indicator />}
|
||||
</Dropdown.Item>
|
||||
</Link>
|
||||
<Link href='/credits' passHref legacyBehavior>
|
||||
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
|
||||
|
@ -58,7 +58,9 @@ function Notification ({ n, fresh }) {
|
||||
(type === 'InvoicePaid' && (n.invoice.nostr ? <NostrZap n={n} /> : <InvoicePaid n={n} />)) ||
|
||||
(type === 'WithdrawlPaid' && <WithdrawlPaid n={n} />) ||
|
||||
(type === 'Referral' && <Referral n={n} />) ||
|
||||
(type === 'Streak' && <Streak n={n} />) ||
|
||||
(type === 'CowboyHat' && <CowboyHat n={n} />) ||
|
||||
(['NewHorse', 'LostHorse'].includes(type) && <Horse n={n} />) ||
|
||||
(['NewGun', 'LostGun'].includes(type) && <Gun n={n} />) ||
|
||||
(type === 'Votification' && <Votification n={n} />) ||
|
||||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
|
||||
(type === 'Mention' && <Mention n={n} />) ||
|
||||
@ -112,12 +114,14 @@ function NoteHeader ({ color, children, big }) {
|
||||
function NoteItem ({ item, ...props }) {
|
||||
return (
|
||||
<div>
|
||||
{item.title
|
||||
? <Item item={item} itemClassName='pt-0' {...props} />
|
||||
: (
|
||||
<RootProvider root={item.root}>
|
||||
<Comment item={item} noReply includeParent clickToContext {...props} />
|
||||
</RootProvider>)}
|
||||
{item.isJob
|
||||
? <ItemJob item={item} {...props} />
|
||||
: item.title
|
||||
? <Item item={item} itemClassName='pt-0' {...props} />
|
||||
: (
|
||||
<RootProvider root={item.root}>
|
||||
<Comment item={item} noReply includeParent clickToContext {...props} />
|
||||
</RootProvider>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -163,7 +167,7 @@ const defaultOnClick = n => {
|
||||
if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` }
|
||||
if (type === 'Referral') return { href: '/referrals/month' }
|
||||
if (type === 'ReferralReward') return { href: '/referrals/month' }
|
||||
if (type === 'Streak') return {}
|
||||
if (['CowboyHat', 'NewHorse', 'LostHorse', 'NewGun', 'LostGun'].includes(type)) return {}
|
||||
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
|
||||
|
||||
if (!n.item) return {}
|
||||
@ -172,30 +176,64 @@ const defaultOnClick = n => {
|
||||
return itemLink(n.item)
|
||||
}
|
||||
|
||||
function Streak ({ n }) {
|
||||
function blurb (n) {
|
||||
const type = n.type ?? 'COWBOY_HAT'
|
||||
const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length)
|
||||
if (n.days) {
|
||||
return `After ${numWithUnits(n.days, {
|
||||
abbreviate: false,
|
||||
unitSingular: 'day',
|
||||
unitPlural: 'days'
|
||||
})}, ` + LOST_BLURBS[type][index]
|
||||
}
|
||||
function blurb (n) {
|
||||
const type = n.__typename === 'CowboyHat'
|
||||
? 'COWBOY_HAT'
|
||||
: (n.__typename.includes('Horse') ? 'HORSE' : 'GUN')
|
||||
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]
|
||||
}
|
||||
|
||||
return FOUND_BLURBS[type][index]
|
||||
function CowboyHat ({ n }) {
|
||||
const Icon = n.days ? BaldIcon : CowboyHatIcon
|
||||
|
||||
let body = ''
|
||||
if (n.days) {
|
||||
body = `After ${numWithUnits(n.days, {
|
||||
abbreviate: false,
|
||||
unitSingular: 'day',
|
||||
unitPlural: 'days'
|
||||
})}, `
|
||||
}
|
||||
|
||||
const Icon = n.days
|
||||
? n.type === 'GUN' ? HolsterIcon : n.type === 'HORSE' ? SaddleIcon : BaldIcon
|
||||
: n.type === 'GUN' ? GunIcon : n.type === 'HORSE' ? HorseIcon : CowboyHatIcon
|
||||
body += `you ${n.days ? 'lost your' : 'found a'} cowboy hat`
|
||||
|
||||
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 {n.days ? 'lost your' : 'found a'} {n.type.toLowerCase().replace('_', ' ')}</span>
|
||||
<span className='fw-bold'>{body}</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>
|
||||
</div>
|
||||
@ -700,7 +738,7 @@ function Reminder ({ n }) {
|
||||
return (
|
||||
<>
|
||||
<NoteHeader color='info'>
|
||||
you asked to be reminded of this {n.item.title ? 'post' : 'comment'}
|
||||
you requested this reminder
|
||||
</NoteHeader>
|
||||
<NoteItem item={n.item} />
|
||||
</>
|
||||
|
@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
import styles from './pay-bounty.module.css'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import { useMe } from './me'
|
||||
@ -7,9 +6,10 @@ import { numWithUnits } from '@/lib/format'
|
||||
import { useShowModal } from './modal'
|
||||
import { useRoot } from './root'
|
||||
import { ActCanceledError, useAct } from './item-act'
|
||||
import { useLightning } from './lightning'
|
||||
import { useAnimation } from '@/components/animation'
|
||||
import { useToast } from './toast'
|
||||
import { useSendWallets } from '@/wallets/index'
|
||||
import { useHasSendWallet } from '@/wallets/client/hooks'
|
||||
import { Form, SubmitButton } from './form'
|
||||
|
||||
export const payBountyCacheMods = {
|
||||
onPaid: (cache, { data }) => {
|
||||
@ -48,11 +48,11 @@ export default function PayBounty ({ children, item }) {
|
||||
const { me } = useMe()
|
||||
const showModal = useShowModal()
|
||||
const root = useRoot()
|
||||
const strike = useLightning()
|
||||
const animate = useAnimation()
|
||||
const toaster = useToast()
|
||||
const wallets = useSendWallets()
|
||||
const hasSendWallet = useHasSendWallet()
|
||||
|
||||
const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet: wallets.length > 0 }
|
||||
const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet }
|
||||
const act = useAct({
|
||||
variables,
|
||||
optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } },
|
||||
@ -61,7 +61,7 @@ export default function PayBounty ({ children, item }) {
|
||||
|
||||
const handlePayBounty = async onCompleted => {
|
||||
try {
|
||||
strike()
|
||||
animate()
|
||||
const { error } = await act({ onCompleted })
|
||||
if (error) throw error
|
||||
} catch (error) {
|
||||
@ -90,11 +90,12 @@ export default function PayBounty ({ children, item }) {
|
||||
<div className='text-center fw-bold text-muted'>
|
||||
Pay this bounty to {item.user.name}?
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<Button className='mt-4' variant='primary' onClick={() => handlePayBounty(onClose)}>
|
||||
pay <small>{numWithUnits(root.bounty)}</small>
|
||||
</Button>
|
||||
</div>
|
||||
{/* initial={{ id: item.id }} is a hack to allow SubmitButton to be used as a button */}
|
||||
<Form className='text-center' onSubmit={() => handlePayBounty(onClose)} initial={{ id: item.id }}>
|
||||
<SubmitButton className='mt-4' variant='primary' submittingText='paying...' appendText={numWithUnits(root.bounty)}>
|
||||
pay
|
||||
</SubmitButton>
|
||||
</Form>
|
||||
</>
|
||||
))
|
||||
}}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DateTimeInput, Form, Input, MarkdownInput, VariableInput } from '@/components/form'
|
||||
import { Checkbox, DateTimeInput, Form, Input, MarkdownInput, VariableInput } from '@/components/form'
|
||||
import { useApolloClient } from '@apollo/client'
|
||||
import Countdown from './countdown'
|
||||
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
||||
@ -30,6 +30,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
||||
text: item?.text || '',
|
||||
options: initialOptions || ['', ''],
|
||||
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
|
||||
randPollOptions: item?.poll?.randPollOptions || false,
|
||||
pollExpiresAt: item ? item.pollExpiresAt : datePivot(new Date(), { hours: 25 }),
|
||||
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
|
||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||
@ -68,6 +69,11 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
||||
label='poll expiration'
|
||||
name='pollExpiresAt'
|
||||
className='pr-4'
|
||||
groupClassName='mb-0'
|
||||
/>
|
||||
<Checkbox
|
||||
label={<div className='d-flex align-items-center'>randomize order of poll choices</div>}
|
||||
name='randPollOptions'
|
||||
/>
|
||||
</AdvPostForm>
|
||||
<ItemButtonBar itemId={item?.id} />
|
||||
|
@ -110,7 +110,7 @@ export function PostForm ({ type, sub, children }) {
|
||||
noForm
|
||||
size='medium'
|
||||
sub={sub?.name}
|
||||
info={sub && <TerritoryInfo sub={sub} />}
|
||||
info={sub && <TerritoryInfo sub={sub} includeLink />}
|
||||
hint={sub?.moderated && 'this territory is moderated'}
|
||||
/>
|
||||
<div>
|
||||
@ -176,7 +176,7 @@ export default function Post ({ sub }) {
|
||||
className='d-flex'
|
||||
size='medium'
|
||||
label='territory'
|
||||
info={sub && <TerritoryInfo sub={sub} />}
|
||||
info={sub && <TerritoryInfo sub={sub} includeLink />}
|
||||
hint={sub?.moderated && 'this territory is moderated'}
|
||||
/>}
|
||||
</PostForm>
|
||||
|
53
components/preserve-scroll.js
Normal file
53
components/preserve-scroll.js
Normal file
@ -0,0 +1,53 @@
|
||||
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,6 +13,7 @@ import { useRoot } from './root'
|
||||
import { CREATE_COMMENT } from '@/fragments/paidAction'
|
||||
import useItemSubmit from './use-item-submit'
|
||||
import gql from 'graphql-tag'
|
||||
import { updateAncestorsCommentCount } from '@/lib/comments'
|
||||
|
||||
export default forwardRef(function Reply ({
|
||||
item,
|
||||
@ -82,17 +83,7 @@ export default forwardRef(function Reply ({
|
||||
const ancestors = item.path.split('.')
|
||||
|
||||
// update all ancestors
|
||||
ancestors.forEach(id => {
|
||||
cache.modify({
|
||||
id: `Item:${id}`,
|
||||
fields: {
|
||||
ncomments (existingNComments = 0) {
|
||||
return existingNComments + 1
|
||||
}
|
||||
},
|
||||
optimistic: true
|
||||
})
|
||||
})
|
||||
updateAncestorsCommentCount(cache, ancestors, 1)
|
||||
|
||||
// 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
|
||||
|
@ -1,22 +1,26 @@
|
||||
import Container from 'react-bootstrap/Container'
|
||||
import styles from './search.module.css'
|
||||
import SearchIcon from '@/svgs/search-line.svg'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Form, Input, Select, DatePicker, SubmitButton } from './form'
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
DatePicker,
|
||||
SubmitButton,
|
||||
useDualAutocomplete,
|
||||
DualAutocompleteWrapper
|
||||
} from './form'
|
||||
import { useRouter } from 'next/router'
|
||||
import { whenToFrom } from '@/lib/time'
|
||||
import { useMe } from './me'
|
||||
import { useField } from 'formik'
|
||||
|
||||
export default function Search ({ sub }) {
|
||||
const router = useRouter()
|
||||
const [q, setQ] = useState(router.query.q || '')
|
||||
const inputRef = useRef(null)
|
||||
const { me } = useMe()
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const search = async values => {
|
||||
let prefix = ''
|
||||
if (sub) {
|
||||
@ -63,18 +67,13 @@ export default function Search ({ sub }) {
|
||||
onSubmit={values => search({ ...values })}
|
||||
>
|
||||
<div className={`${styles.active} mb-3`}>
|
||||
<Input
|
||||
<SearchInput
|
||||
name='q'
|
||||
required
|
||||
autoFocus
|
||||
groupClassName='me-3 mb-0 flex-grow-1'
|
||||
className='flex-grow-1'
|
||||
clear
|
||||
innerRef={inputRef}
|
||||
overrideValue={q}
|
||||
onChange={async (formik, e) => {
|
||||
setQ(e.target.value?.trim())
|
||||
}}
|
||||
setOuterQ={setQ}
|
||||
/>
|
||||
<SubmitButton variant='primary' className={styles.search}>
|
||||
<SearchIcon width={22} height={22} />
|
||||
@ -135,3 +134,52 @@ 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,20 +1,16 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { Workbox } from 'workbox-window'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import { detectOS, useServiceWorkerLogger } from './logger'
|
||||
import { requestPersistentStorage } from './use-indexeddb'
|
||||
|
||||
const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY
|
||||
|
||||
const ServiceWorkerContext = createContext()
|
||||
|
||||
// message types for communication between app and service worker
|
||||
export const MESSAGE_PORT = 'MESSAGE_PORT' // message to exchange message channel on which service worker will send messages back to app
|
||||
export const ACTION_PORT = 'ACTION_PORT' // message to exchange action channel on which service worker will send actions back to app
|
||||
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 DELETE_SUBSCRIPTION = 'DELETE_SUBSCRIPTION'
|
||||
export const STORE_SUBSCRIPTION = 'STORE_SUBSCRIPTION'
|
||||
export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS'
|
||||
|
||||
export const ServiceWorkerProvider = ({ children }) => {
|
||||
const [registration, setRegistration] = useState(null)
|
||||
@ -38,13 +34,12 @@ export const ServiceWorkerProvider = ({ children }) => {
|
||||
`)
|
||||
const [deletePushSubscription] = useMutation(
|
||||
gql`
|
||||
mutation deletePushSubscription($endpoint: String!) {
|
||||
deletePushSubscription(endpoint: $endpoint) {
|
||||
id
|
||||
}
|
||||
mutation deletePushSubscription($endpoint: String!) {
|
||||
deletePushSubscription(endpoint: $endpoint) {
|
||||
id
|
||||
}
|
||||
`)
|
||||
const logger = useServiceWorkerLogger()
|
||||
}
|
||||
`)
|
||||
|
||||
// I am not entirely sure if this is needed since at least in Brave,
|
||||
// using `registration.pushManager.subscribe` also prompts the user.
|
||||
@ -77,7 +72,6 @@ export const ServiceWorkerProvider = ({ children }) => {
|
||||
// see https://stackoverflow.com/a/69624651
|
||||
let pushSubscription = await registration.pushManager.subscribe(subscribeOptions)
|
||||
const { endpoint } = pushSubscription
|
||||
logger.info('subscribed to push notifications', { endpoint })
|
||||
// convert keys from ArrayBuffer to string
|
||||
pushSubscription = JSON.parse(JSON.stringify(pushSubscription))
|
||||
// Send subscription to service worker to save it so we can use it later during `pushsubscriptionchange`
|
||||
@ -86,7 +80,8 @@ export const ServiceWorkerProvider = ({ children }) => {
|
||||
action: STORE_SUBSCRIPTION,
|
||||
subscription: pushSubscription
|
||||
})
|
||||
logger.info('sent STORE_SUBSCRIPTION to service worker', { endpoint })
|
||||
requestPersistentStorage()
|
||||
|
||||
// send subscription to server
|
||||
const variables = {
|
||||
endpoint,
|
||||
@ -94,34 +89,21 @@ export const ServiceWorkerProvider = ({ children }) => {
|
||||
auth: pushSubscription.keys.auth
|
||||
}
|
||||
await savePushSubscription({ variables })
|
||||
logger.info('sent push subscription to server', { endpoint })
|
||||
}
|
||||
|
||||
const unsubscribeFromPushNotifications = async (subscription) => {
|
||||
await subscription.unsubscribe()
|
||||
const { endpoint } = subscription
|
||||
logger.info('unsubscribed from push notifications', { 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 })
|
||||
logger.info('deleted push subscription from server', { endpoint })
|
||||
}
|
||||
|
||||
const togglePushSubscription = useCallback(async () => {
|
||||
const pushSubscription = await registration.pushManager.getSubscription()
|
||||
if (pushSubscription) {
|
||||
return unsubscribeFromPushNotifications(pushSubscription)
|
||||
return await unsubscribeFromPushNotifications(pushSubscription)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
await subscribeToPushNotifications()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@ -133,37 +115,15 @@ export const ServiceWorkerProvider = ({ children }) => {
|
||||
setPermission({ notification: 'Notification' in window ? window.Notification.permission : 'denied' })
|
||||
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
logger.info('device does not support service worker')
|
||||
return
|
||||
}
|
||||
|
||||
const wb = new Workbox('/sw.js', { scope: '/' })
|
||||
wb.register().then(registration => {
|
||||
logger.info('service worker registration successful')
|
||||
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(() => ({
|
||||
registration,
|
||||
support,
|
||||
@ -179,6 +139,10 @@ export const ServiceWorkerProvider = ({ children }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export function clearNotifications () {
|
||||
return navigator.serviceWorker?.controller?.postMessage({ action: CLEAR_NOTIFICATIONS })
|
||||
}
|
||||
|
||||
export function useServiceWorker () {
|
||||
return useContext(ServiceWorkerContext)
|
||||
}
|
||||
|
@ -20,6 +20,25 @@ export default function SubscribeDropdownItem ({ item: { id, meSubscription } })
|
||||
},
|
||||
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,3 +1,4 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { Badge, Button, CardFooter, Dropdown } from 'react-bootstrap'
|
||||
import { AccordianCard } from './accordian-item'
|
||||
import TerritoryPaymentDue, { TerritoryBillingLine } from './territory-payment-due'
|
||||
@ -13,6 +14,16 @@ import { useToast } from './toast'
|
||||
import ActionDropdown from './action-dropdown'
|
||||
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 }) {
|
||||
return (
|
||||
<AccordianCard
|
||||
@ -42,9 +53,10 @@ export function TerritoryInfoSkeleton ({ children, className }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function TerritoryInfo ({ sub }) {
|
||||
export function TerritoryInfo ({ sub, includeLink }) {
|
||||
return (
|
||||
<>
|
||||
{includeLink && <Link href={`/~${sub.name}`}>{sub.name}</Link>}
|
||||
<div className='py-2'>
|
||||
<Text>{sub.desc}</Text>
|
||||
</div>
|
||||
@ -148,12 +160,15 @@ export default function TerritoryHeader ({ sub }) {
|
||||
|
||||
export function MuteSubDropdownItem ({ item, sub }) {
|
||||
const toaster = useToast()
|
||||
const { refetchQueries } = useSubscribeTerritoryContext()
|
||||
|
||||
const [toggleMuteSub] = useMutation(
|
||||
gql`
|
||||
mutation toggleMuteSub($name: String!) {
|
||||
toggleMuteSub(name: $name)
|
||||
}`, {
|
||||
refetchQueries,
|
||||
awaitRefetchQueries: true,
|
||||
update (cache, { data: { toggleMuteSub } }) {
|
||||
cache.modify({
|
||||
id: `Sub:{"name":"${sub.name}"}`,
|
||||
@ -212,11 +227,14 @@ export function PinSubDropdownItem ({ item: { id, position } }) {
|
||||
|
||||
export function ToggleSubSubscriptionDropdownItem ({ sub: { name, meSubscription } }) {
|
||||
const toaster = useToast()
|
||||
const { refetchQueries } = useSubscribeTerritoryContext()
|
||||
const [toggleSubSubscription] = useMutation(
|
||||
gql`
|
||||
mutation toggleSubSubscription($name: String!) {
|
||||
toggleSubSubscription(name: $name)
|
||||
}`, {
|
||||
refetchQueries,
|
||||
awaitRefetchQueries: true,
|
||||
update (cache, { data: { toggleSubSubscription } }) {
|
||||
cache.modify({
|
||||
id: `Sub:{"name":"${name}"}`,
|
||||
|
@ -5,8 +5,10 @@ import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import MoreFooter from './more-footer'
|
||||
import { useData } from './use-data'
|
||||
import { useMe } from './me'
|
||||
import Info from './info'
|
||||
import { TerritoryInfo } from './territory-header'
|
||||
import ActionDropdown from './action-dropdown'
|
||||
import { TerritoryInfo, ToggleSubSubscriptionDropdownItem, MuteSubDropdownItem } from './territory-header'
|
||||
|
||||
// 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>)
|
||||
@ -35,16 +37,17 @@ function separate (arr, separator) {
|
||||
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, separator] : [x])
|
||||
}
|
||||
|
||||
export default function TerritoryList ({ ssrData, query, variables, destructureData, rank }) {
|
||||
export default function TerritoryList ({ ssrData, query, variables, destructureData, rank, subActionDropdown, statCompsProp = STAT_COMPONENTS }) {
|
||||
const { data, fetchMore } = useQuery(query, { variables })
|
||||
const dat = useData(data, ssrData)
|
||||
const [statComps, setStatComps] = useState(separate(STAT_COMPONENTS, Separator))
|
||||
const { me } = useMe()
|
||||
const [statComps, setStatComps] = useState(separate(statCompsProp, Separator))
|
||||
|
||||
useEffect(() => {
|
||||
// shift the stat we are sorting by to the front
|
||||
const comps = [...STAT_COMPONENTS]
|
||||
const comps = [...statCompsProp]
|
||||
setStatComps(separate([...comps.splice(STAT_POS[variables?.by || 0], 1), ...comps], Separator))
|
||||
}, [variables?.by])
|
||||
}, [variables?.by], statCompsProp)
|
||||
|
||||
const { subs, cursor } = useMemo(() => {
|
||||
if (!dat) return {}
|
||||
@ -77,6 +80,12 @@ export default function TerritoryList ({ ssrData, query, variables, destructureD
|
||||
{sub.name}
|
||||
</Link>
|
||||
<Info className='d-flex'><TerritoryInfo sub={sub} /></Info>
|
||||
{me && subActionDropdown && (
|
||||
<ActionDropdown>
|
||||
<ToggleSubSubscriptionDropdownItem sub={sub} />
|
||||
<MuteSubDropdownItem sub={sub} />
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.other}>
|
||||
{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) {
|
||||
return href
|
||||
}
|
||||
|
||||
const isHashLink = href.startsWith('#')
|
||||
// eslint-disable-next-line
|
||||
return <Link id={props.id} target='_blank' rel={rel} href={href}>{children}</Link>
|
||||
return <Link id={props.id} target={isHashLink ? undefined : '_blank'} rel={rel} href={href}>{children}</Link>
|
||||
},
|
||||
img: TextMediaOrLink,
|
||||
embed: Embed
|
||||
embed: (props) => <Embed {...props} topLevel={topLevel} />
|
||||
}), [outlawed, rel, TextMediaOrLink, topLevel])
|
||||
|
||||
const carousel = useCarousel()
|
||||
|
@ -252,11 +252,18 @@
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
.text li > :is(.twitterContainer, .nostrContainer, .wavlakeWrapper, .spotifyWrapper, .onlyImages) {
|
||||
display: inline-flex;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text ul,
|
||||
.text ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0rem;
|
||||
padding-left: 2rem;
|
||||
max-width: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.text ol ol,
|
||||
|
33
components/use-cookie.js
Normal file
33
components/use-cookie.js
Normal file
@ -0,0 +1,33 @@
|
||||
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\noriginally posted at https://stacker.news/items/${item.id}`
|
||||
content += `\n\nhttps://stacker.news/items/${item.id}`
|
||||
|
||||
return content.trim()
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
|
||||
import { clearNotifications } from '@/lib/badge'
|
||||
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import React, { useContext } from 'react'
|
||||
import { clearNotifications } from '@/components/serviceworker'
|
||||
|
||||
export const HasNewNotesContext = React.createContext(false)
|
||||
|
||||
|
@ -1,300 +1,180 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useMe } from '@/components/me'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
export function getDbName (userId, name) {
|
||||
return `app:storage:${userId ?? ''}${name ? `:${name}` : ''}`
|
||||
}
|
||||
const VERSION = 2
|
||||
|
||||
const DEFAULT_OPTIONS = { keyPath: 'id', autoIncrement: true }
|
||||
const DEFAULT_INDICES = []
|
||||
const DEFAULT_VERSION = 1
|
||||
export function useIndexedDB (dbName) {
|
||||
const { me } = useMe()
|
||||
if (!dbName) dbName = me?.id ? `app:storage:${me.id}` : 'app:storage'
|
||||
|
||||
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
|
||||
const set = useCallback(async (storeName, key, value) => {
|
||||
const db = await _open(dbName, VERSION)
|
||||
|
||||
try {
|
||||
// try to run a noop to see if the db is ready
|
||||
db.transaction(storeName)
|
||||
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) {
|
||||
handleError(error)
|
||||
return await _set(db, storeName, key, value)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}, [dbName, storeName, handleError, operationQueue])
|
||||
}, [dbName])
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
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) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
if (typeof window.indexedDB === 'undefined') {
|
||||
return reject(new IndexedDBOpenError('IndexedDB unavailable'))
|
||||
}
|
||||
|
||||
const request = window.indexedDB.open(dbName, version)
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
try {
|
||||
const db = event.target.result
|
||||
if (!db.objectStoreNames.contains('vault')) db.createObjectStore('vault')
|
||||
if (db.objectStoreNames.contains('wallet_logs')) db.deleteObjectStore('wallet_logs')
|
||||
} catch (error) {
|
||||
reject(new IndexedDBOpenError(`upgrade failed: ${error?.message}`))
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject(new IndexedDBOpenError(request.error?.message))
|
||||
}
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const db = request.result
|
||||
resolve(db)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function _set (db, storeName, key, value) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let request
|
||||
try {
|
||||
if (!window.indexedDB) {
|
||||
console.log('IndexedDB is not supported')
|
||||
setNotSupported(true)
|
||||
return
|
||||
}
|
||||
|
||||
request = window.indexedDB.open(dbName, version)
|
||||
|
||||
request.onerror = (event) => {
|
||||
handleError(new Error('Error opening database'))
|
||||
}
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
if (isMounted) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const database = event.target.result
|
||||
try {
|
||||
const store = database.createObjectStore(storeName, options)
|
||||
|
||||
indices.forEach(index => {
|
||||
store.createIndex(index.name, index.keyPath, index.options)
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(new Error('Error upgrading database: ' + error.message))
|
||||
}
|
||||
}
|
||||
request = db
|
||||
.transaction(storeName, 'readwrite')
|
||||
.objectStore(storeName)
|
||||
.put(value, key)
|
||||
} catch (error) {
|
||||
handleError(new Error('Error opening database: ' + error.message))
|
||||
return reject(new IndexedDBSetError(error?.message))
|
||||
}
|
||||
|
||||
return () => {
|
||||
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.onerror = (event) => {
|
||||
reject(new IndexedDBSetError(event.target?.error?.message))
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const wrappedOperation = (db) => {
|
||||
try {
|
||||
const result = operation(db)
|
||||
resolve(result)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default useIndexedDB
|
||||
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) {
|
||||
return reject(new IndexedDBGetError(error?.message))
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject(new IndexedDBGetError(event.target?.error?.message))
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!('persisted' in navigator.storage) || !('persist' in navigator.storage)) {
|
||||
throw new Error('persistent storage not supported')
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
class IndexedDBError extends Error {
|
||||
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 { useCallback, useMemo } from 'react'
|
||||
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors'
|
||||
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/client/errors'
|
||||
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
|
||||
import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet'
|
||||
import { INVOICE, CANCEL_INVOICE } from '@/fragments/invoice'
|
||||
|
||||
export default function useInvoice () {
|
||||
const client = useApolloClient()
|
||||
|
@ -8,6 +8,7 @@ import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
|
||||
import gql from 'graphql-tag'
|
||||
import { USER_ID } from '@/lib/constants'
|
||||
import { useMe } from './me'
|
||||
import { useWalletRecvPrompt, WalletPromptClosed } from '@/wallets/client/hooks'
|
||||
|
||||
// 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
|
||||
@ -22,9 +23,17 @@ export default function useItemSubmit (mutation,
|
||||
const crossposter = useCrossposter()
|
||||
const [upsertItem] = usePaidMutation(mutation)
|
||||
const { me } = useMe()
|
||||
const walletPrompt = useWalletRecvPrompt()
|
||||
|
||||
return useCallback(
|
||||
async ({ boost, crosspost, title, options, bounty, status, ...values }, { resetForm }) => {
|
||||
try {
|
||||
await walletPrompt()
|
||||
} catch (err) {
|
||||
if (err instanceof WalletPromptClosed) return
|
||||
throw err
|
||||
}
|
||||
|
||||
if (options) {
|
||||
// remove existing poll options since else they will be appended as duplicates
|
||||
options = options.slice(item?.poll?.options?.length || 0).filter(o => o.trim().length > 0)
|
||||
@ -93,7 +102,7 @@ export default function useItemSubmit (mutation,
|
||||
}
|
||||
}
|
||||
}, [me, upsertItem, router, crossposter, item, sub, onSuccessfulSubmit,
|
||||
navigateOnSubmit, extraValues, paidMutationOptions]
|
||||
navigateOnSubmit, extraValues, paidMutationOptions, walletPrompt]
|
||||
)
|
||||
}
|
||||
|
||||
|
124
components/use-live-comments.js
Normal file
124
components/use-live-comments.js
Normal file
@ -0,0 +1,124 @@
|
||||
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 useQrPayment from '@/components/use-qr-payment'
|
||||
import useInvoice from '@/components/use-invoice'
|
||||
import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/errors'
|
||||
import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/client/errors'
|
||||
import { GET_PAID_ACTION } from '@/fragments/paidAction'
|
||||
import { useWalletPayment } from '@/wallets/payment'
|
||||
import { useWalletPayment } from '@/wallets/client/hooks'
|
||||
|
||||
/*
|
||||
this is just like useMutation with a few changes:
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { useCallback } from 'react'
|
||||
import Invoice from '@/components/invoice'
|
||||
import { InvoiceCanceledError, InvoiceExpiredError, AnonWalletError } from '@/wallets/errors'
|
||||
import { AnonWalletError, InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/client/errors'
|
||||
import { useShowModal } from '@/components/modal'
|
||||
import useInvoice from '@/components/use-invoice'
|
||||
import { sendPayment } from '@/wallets/webln/client'
|
||||
import { sendPayment as weblnSendPayment } from '@/wallets/client/protocols/webln'
|
||||
|
||||
export default function useQrPayment () {
|
||||
const invoice = useInvoice()
|
||||
@ -19,7 +19,7 @@ export default function useQrPayment () {
|
||||
) => {
|
||||
// if anon user and webln is available, try to pay with webln
|
||||
if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) {
|
||||
sendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) })
|
||||
weblnSendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) })
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
let paid
|
||||
|
32
components/use-visibility.js
Normal file
32
components/use-visibility.js
Normal file
@ -0,0 +1,32 @@
|
||||
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,6 +8,7 @@ import { useState, useEffect } from 'react'
|
||||
import { Form, Input, SubmitButton } from './form'
|
||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||
import styles from './user-header.module.css'
|
||||
import navStyles from '@/styles/nav.module.css'
|
||||
import { useMe } from './me'
|
||||
import { NAME_MUTATION } from '@/fragments/users'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
@ -28,9 +29,11 @@ import { hexToBech32 } from '@/lib/nostr'
|
||||
import NostrIcon from '@/svgs/nostr.svg'
|
||||
import GithubIcon from '@/svgs/github-fill.svg'
|
||||
import TwitterIcon from '@/svgs/twitter-fill.svg'
|
||||
import { UNKNOWN_LINK_REL, MEDIA_URL } from '@/lib/constants'
|
||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||
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 }) {
|
||||
const router = useRouter()
|
||||
|
||||
@ -42,7 +45,7 @@ export default function UserHeader ({ user }) {
|
||||
<>
|
||||
<HeaderHeader user={user} />
|
||||
<Nav
|
||||
className={styles.nav}
|
||||
className={navStyles.nav}
|
||||
activeKey={activeKey}
|
||||
>
|
||||
<Nav.Item>
|
||||
|
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