Compare commits

..

No commits in common. "71ce403b0ce9ead5d0f0bdc3f2eeca9a95bbf475" and "eaa15b3b43c370d4189b1c303e3d047de857b1df" have entirely different histories.

392 changed files with 51979 additions and 101143 deletions

View File

@ -1,4 +0,0 @@
# Project review guidelines
- ignore ??? as a placeholder in awards.csv

View File

@ -32,12 +32,6 @@ SLACK_CHANNEL_ID=
LNAUTH_URL=http://localhost:3000/api/lnauth LNAUTH_URL=http://localhost:3000/api/lnauth
LNWITH_URL=http://localhost:3000/api/lnwith LNWITH_URL=http://localhost:3000/api/lnwith
# auto social poster
TWITTER_POSTER_API_KEY=
TWITTER_POSTER_API_KEY_SECRET=
TWITTER_POSTER_ACCESS_TOKEN=
TWITTER_POSTER_ACCESS_TOKEN_SECRET=
######################################## ########################################
# SNDEV STUFF WE PRESET # # SNDEV STUFF WE PRESET #
# which you can override in .env.local # # which you can override in .env.local #
@ -63,8 +57,8 @@ INVOICE_HMAC_KEY=a4c1d9c81edb87b79d28809876a18cf72293eadb39f92f3f4f2f1cfbdf907c9
# lnd # lnd
# xxd -p -c0 docker/lnd/sn/regtest/admin.macaroon # xxd -p -c0 docker/lnd/sn/regtest/admin.macaroon
LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a4343416569674177494241674951484f4a69597458736c72592f4931376933574c444354414b42676771686b6a4f50515144416a41344d5238770a485159445651514b45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d52557745775944565151444577777a4e54526d4d574e690a4f546b7a595451774868634e4d6a55774e54497a4d4467784d444d345768634e4d6a59774e7a45344d4467784d444d34576a41344d523877485159445651514b0a45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d52557745775944565151444577777a4e54526d4d574e694f546b7a595451770a5754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e434141524b6d733131422b4e58554e642f54574347492b4b2b5046686b485a31410a5449647732566e766a344f6130784c696c515a4d7779647149586c7a724641485064646a3566697934584c456f43364d4e427636585277706f3448544d4948510a4d41344741315564447745422f775145417749437044415442674e56485355454444414b4267677242674546425163444154415042674e5648524d42416638450a425441444151482f4d42304741315564446751574242526f433554634e58746366464f7458393171364364337a6930327a54423542674e5648524545636a42770a6767777a4e54526d4d574e694f546b7a5954534343577876593246736147397a64494947633235666247356b6768526f62334e304c6d52765932746c636935700a626e526c636d356862494945645735706549494b64573570654842685932746c64494948596e566d59323975626f6345667741414159635141414141414141410a4141414141414141414141414159634572424941427a414b42676771686b6a4f5051514441674e494144424641694541324941462b32436746704a754e5445750a34524f63322f70625870476f4934365573724a65525972614d33414349423974424c6759777a597a2b596b5a4e7a417a7077454c754935564f505959724a6f6b0a7270754d32316b690a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a43434165696741774942416749516139493834682b48653350385a437541525854554d54414b42676771686b6a4f50515144416a41344d5238770a485159445651514b45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d5255774577594456515144457778694e6a41785a5749780a4d474d354f444d774868634e4d6a51774d7a41334d5463774d6a45355768634e4d6a55774e5441794d5463774d6a4535576a41344d523877485159445651514b0a45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d5255774577594456515144457778694e6a41785a5749784d474d354f444d770a5754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e4341415365596a4b62542b4a4a4a37624b6770677a6d6c3278496130364e3174680a2f4f7033533173382b4f4a41387836647849682f326548556b4f7578675a36703549434b496f375a544c356a5963764375793941334b6e466f3448544d4948510a4d41344741315564447745422f775145417749437044415442674e56485355454444414b4267677242674546425163444154415042674e5648524d42416638450a425441444151482f4d4230474131556444675157424252545756796e653752786f747568717354727969466d6a36736c557a423542674e5648524545636a42770a676778694e6a41785a5749784d474d354f444f4343577876593246736147397a64494947633235666247356b6768526f62334e304c6d52765932746c636935700a626e526c636d356862494945645735706549494b64573570654842685932746c64494948596e566d59323975626f6345667741414159635141414141414141410a41414141414141414141414141596345724273414254414b42676771686b6a4f5051514441674e4941444246416945413873616c4a667134476671465557532f0a35347a335461746c6447736673796a4a383035425a5263334f326f434943794e6e3975716976566f5575365935345143624c3966394c575779547a516e61616e0a656977482f51696b0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a
LND_MACAROON=0201036c6e6402f801030a10ba643b9c3fe23f760e1ee63e0196656e1201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620fd0027075985f7073217aa9aaae4d14db0e7ca38f4e572c3b85c81cf6bb580b3 LND_MACAROON=0201036c6e6402f801030a106cf4e146abffa5d766befbbf4c73b5a31201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e6572617465120472656164000006202c3bfd55c191e925cbffd73712c9d4b9b4a8440410bde5f8a0a6e33af8b3d876
LND_SOCKET=sn_lnd:10009 LND_SOCKET=sn_lnd:10009
# nostr (NIP-57 zap receipts) # nostr (NIP-57 zap receipts)
@ -85,7 +79,6 @@ IMGPROXY_READ_TIMEOUT=10
IMGPROXY_WRITE_TIMEOUT=10 IMGPROXY_WRITE_TIMEOUT=10
IMGPROXY_DOWNLOAD_TIMEOUT=9 IMGPROXY_DOWNLOAD_TIMEOUT=9
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1 IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
IMGPROXY_ALLOW_ORIGIN=http://localhost:3000
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1 # IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
# IMGPROXY_ENABLE_DEBUG_HEADERS=true # IMGPROXY_ENABLE_DEBUG_HEADERS=true
@ -140,8 +133,8 @@ SN_LND_REST_PORT=8080
SN_LND_GRPC_PORT=10009 SN_LND_GRPC_PORT=10009
SN_LND_P2P_PORT=9735 SN_LND_P2P_PORT=9735
# docker exec -u lnd sn_lnd lncli newaddress p2wkh --unused # docker exec -u lnd sn_lnd lncli newaddress p2wkh --unused
SN_LND_PUBKEY=03dc0de8fbe29ef3d26554c615adfd17aaca959403c4e9ecebaac4b83978d86342 SN_LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl
SN_LND_ADDR=bcrt1qu6g49vrl8n4ay99hr04wefkfy2e8g0z4nc0sjw SN_LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
# sn_lndk stuff # sn_lndk stuff
SN_LNDK_GRPC_PORT=10012 SN_LNDK_GRPC_PORT=10012
@ -184,7 +177,6 @@ grpc_proxy=http://tor:7050/
# lnbits # lnbits
LNBITS_WEB_PORT=5001 LNBITS_WEB_PORT=5001
LNBITS_WEB_PORT_V1=5002
# CPU shares for each category # CPU shares for each category
CPU_SHARES_IMPORTANT=1024 CPU_SHARES_IMPORTANT=1024
@ -192,8 +184,3 @@ CPU_SHARES_MODERATE=512
CPU_SHARES_LOW=256 CPU_SHARES_LOW=256
NEXT_TELEMETRY_DISABLED=1 NEXT_TELEMETRY_DISABLED=1
# custom domains stuff
# local DNS server for custom domain verification, by default it's dnsmasq.
# reachable by containers on 172.30.0.2(:53), outside of docker with 0.0.0.0:5353
DOMAINS_DNS_SERVER=172.30.0.2

View File

@ -10,9 +10,8 @@ _Was anything unclear during your work on this PR? Anything we should definitely
## Checklist ## Checklist
**Are your changes backward compatible? Please answer below:** **Are your changes backwards compatible? Please answer below:**
_For example, a change is not backward compatible if you removed a GraphQL field or dropped a database column._
**On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:** **On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:**

View File

@ -14,10 +14,7 @@ jobs:
github.event_name == 'pull_request_target' && github.event_name == 'pull_request_target' &&
github.event.action == 'closed' && github.event.action == 'closed' &&
github.event.pull_request.merged == true && github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'extend-awards/patch' && github.event.pull_request.head.ref != 'extend-awards/patch'
github.event.pull_request.user.login != 'huumn' &&
github.event.pull_request.user.login != 'ekzyis' &&
github.event.pull_request.user.login != 'Soxasora'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -25,36 +22,14 @@ jobs:
with: with:
python-version: '3.13' python-version: '3.13'
- run: pip install requests - run: pip install requests
- name: Check if branch exists
id: check_branch
run: |
git fetch origin extend-awards/patch || echo "Branch does not exist"
if git show-ref --verify --quiet refs/remotes/origin/extend-awards/patch; then
echo "exists=true" >> $GITHUB_ENV
else
echo "exists=false" >> $GITHUB_ENV
fi
- name: Checkout to existing branch
if: env.exists == 'true'
run: |
git checkout extend-awards/patch
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
- run: python extend-awards.py - run: python extend-awards.py
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_CONTEXT: ${{ toJson(github) }} GITHUB_CONTEXT: ${{ toJson(github) }}
- name: Commit changes and push to existing branch
if: env.exists == 'true'
run: |
git commit -am "Extending awards.csv" || exit 0
git push origin extend-awards/patch
- uses: peter-evans/create-pull-request@v7 - uses: peter-evans/create-pull-request@v7
if: env.exists == 'false'
with: with:
add-paths: awards.csv add-paths: awards.csv
branch: extend-awards/patch branch: extend-awards/patch
commit-message: Extending awards.csv commit-message: Extending awards.csv
title: Extending awards.csv title: Extending awards.csv
body: One or more PR's were merged that solve an issue(s) and awards.csv should be extended. Remember to delete the branch after merging. body: A PR was merged that solves an issue and awards.csv should be extended.
delete-branch: true

9
.gitignore vendored
View File

@ -53,7 +53,6 @@ docker-compose.*.yml
*.sql *.sql
!/prisma/migrations/*/*.sql !/prisma/migrations/*/*.sql
!/docker/db/seed.sql !/docker/db/seed.sql
!/docker/db/wallet-seed.sql
# nostr wallet connect # nostr wallet connect
scripts/nwc-keys.json scripts/nwc-keys.json
@ -67,11 +66,3 @@ docker/lnbits/data
# nostr link extract # nostr link extract
scripts/nostr-link-extract.config.json scripts/nostr-link-extract.config.json
scripts/nostr-links.db scripts/nostr-links.db
scripts/twitter-link-extract.config.json
scripts/twitter-links.db
# pay-awards
scripts/pay-awards.config.json
# dnsmasq
docker/dnsmasq/dnsmasq.d/*

View File

@ -87,9 +87,6 @@ COMMANDS
psql open psql on db psql open psql on db
prisma run prisma commands prisma run prisma commands
domains:
domains custom domains dev management
dev: dev:
pr fetch and checkout a pr pr fetch and checkout a pr
lint run linters lint run linters
@ -134,36 +131,7 @@ services:
You can read more about [docker compose override files](https://docs.docker.com/compose/multiple-compose-files/merge/). You can read more about [docker compose override files](https://docs.docker.com/compose/multiple-compose-files/merge/).
#### Enabling semantic search
To enable semantic search that uses text embeddings, run `./scripts/nlp-setup`.
Before running `./scripts/nlp-setup`, ensure the following are true:
- search is enabled in `COMPOSE_PROFILES`:
```.env
COMPOSE_PROFILES=...,search,...
```
- The default opensearch index (default name=`item`) is created and done indexing. This should happen the first time you run `./sndev start`, but it may take a few minutes for indexing to complete.
After `nlp-setup` is done, restart your containers to enable semantic search:
```
> ./sndev restart
```
#### Local DNS via dnsmasq
To enable dnsmasq:
- domains should be enabled in `COMPOSE_PROFILES`:
```.env
COMPOSE_PROFILES=...,domains,...
```
To add/remove DNS records you can now use `./sndev domains dns`. More on this [here](#add-or-remove-dns-records-in-local).
<br> <br>
@ -463,25 +431,6 @@ To enable Web Push locally, you will need to set the `VAPID_*` env vars. `VAPID_
<br> <br>
## Custom domains
### Add or remove DNS records in local
A worker dedicated to verifying custom domains, checks, among other things, if a domain has the correct DNS records and values. This would normally require a real domain and access to its DNS configuration. Therefore we use dnsmasq to have local DNS, make sure you have [enabled it](#local-dns-via-dnsmasq).
To add a DNS record the syntax is the following:
`./sndev domains dns add|remove cname|txt <name/domain> <value>`
For TXT records, you can also use `""` quoted strings on `value`.
To list all DNS records present in the dnsmasq config: `./sndev domains dns list`
#### Access a local custom domain added via dnsmasq
sndev will use the dnsmasq DNS server by default, but chances are that you might want to access the domain via your browser.
For every edit on dnsmasq, it will give you the option to either edit the `/etc/hosts` file or use the dnsmasq DNS server which can be reached on `127.0.0.1:5353`. You can avoid getting asked to edit the `/etc/hosts` file by adding the `--no-hosts` parameter.
# Internals # Internals
<br> <br>
@ -519,7 +468,7 @@ Open a [discussion](http://github.com/stackernews/stacker.news/discussions) or [
# Responsible disclosure # Responsible disclosure
If you found a vulnerability, we would greatly appreciate it if you contact us via [security@stacker.news](mailto:security@stacker.news) or open a [security advisory](https://github.com/stackernews/stacker.news/security/advisories/new). Our PGP key can be found [here](https://stacker.news/pgp.txt) (FEE1 E768 E0B3 81F5). If you found a vulnerability, we would greatly appreciate it if you contact us via [security@stacker.news](mailto:security@stacker.news) or open a [security advisory](https://github.com/stackernews/stacker.news/security/advisories/new). Our PGP key can be found [here](https://stacker.news/pgp.txt) (EBAF 75DA 7279 CB48).
<br> <br>

View File

@ -103,7 +103,7 @@ stateDiagram-v2
| donations | x | | x | x | x | | | x | | | donations | x | | x | x | x | | | x | |
| update posts | x | | x | | x | | x | x | | | update posts | x | | x | | x | | x | x | |
| update comments | x | | x | | x | | x | x | | | update comments | x | | x | | x | | x | x | |
| receive | | | | | x | x | x | | x | | receive | | x | | | x | x | x | | x |
| buy fee credits | | | x | | x | | | x | | | buy fee credits | | | x | | x | | | x | |
| invite gift | x | | | | | | x | x | | | invite gift | x | | | | | | x | x | |
@ -205,7 +205,7 @@ The ONLY exception to this are for the `users` table where we store a stacker's
We use a `read committed` isolation level for actions. This means paid actions need to be mindful of concurrency issues. Specifically, reading data from the database and then writing it back in `read committed` is a common source of consistency bugs (aka serialization anamolies). We use a `read committed` isolation level for actions. This means paid actions need to be mindful of concurrency issues. Specifically, reading data from the database and then writing it back in `read committed` is a common source of consistency bugs (aka serialization anamolies).
### This is a big deal ### This is a big deal
1. If you read from the database and intend to use that data to write to the database, and it's possible that a concurrent transaction could change the data you've read (it usually is), you need to be prepared to handle that (see [_phantom reads_](https://www.postgresql.org/docs/16/transaction-iso.html)). 1. If you read from the database and intend to use that data to write to the database, and it's possible that a concurrent transaction could change the data you've read (it usually is), you need to be prepared to handle that.
2. This applies to **ALL**, and I really mean **ALL**, read data regardless of how you read the data within the `read committed` transaction: 2. This applies to **ALL**, and I really mean **ALL**, read data regardless of how you read the data within the `read committed` transaction:
- independent statements - independent statements
- `WITH` queries (CTEs) in the same statement - `WITH` queries (CTEs) in the same statement

View File

@ -62,7 +62,7 @@ export async function onPaid ({ invoice, actId }, { tx }) {
// denormalize downzaps // denormalize downzaps
await tx.$executeRaw` await tx.$executeRaw`
WITH territory AS ( WITH territory AS (
SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName" SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
FROM "Item" i FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId" LEFT JOIN "Item" r ON r.id = i."rootId"
WHERE i.id = ${itemAct.itemId}::INTEGER WHERE i.id = ${itemAct.itemId}::INTEGER

View File

@ -227,7 +227,7 @@ async function performP2PAction (actionType, args, incomingContext) {
await assertBelowMaxPendingInvoices(incomingContext) await assertBelowMaxPendingInvoices(incomingContext)
const description = await paidActions[actionType].describe(args, incomingContext) const description = await paidActions[actionType].describe(args, incomingContext)
const { invoice, wrappedInvoice, protocol, maxFee } = await createWrappedInvoice(userId, { const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: cost, msats: cost,
feePercent: sybilFeePercent, feePercent: sybilFeePercent,
description, description,
@ -239,7 +239,7 @@ async function performP2PAction (actionType, args, incomingContext) {
invoiceArgs: { invoiceArgs: {
bolt11: invoice, bolt11: invoice,
wrappedBolt11: wrappedInvoice, wrappedBolt11: wrappedInvoice,
protocol, wallet,
maxFee maxFee
} }
} }
@ -269,7 +269,7 @@ async function performDirectAction (actionType, args, incomingContext) {
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext) const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
for await (const { invoice, logger, protocol } of createUserInvoice(userId, { for await (const { invoice, logger, wallet } of createUserInvoice(userId, {
msats: cost, msats: cost,
description, description,
expiry: INVOICE_EXPIRE_SECS expiry: INVOICE_EXPIRE_SECS
@ -293,7 +293,7 @@ async function performDirectAction (actionType, args, incomingContext) {
bolt11: invoice, bolt11: invoice,
msats: cost, msats: cost,
hash, hash,
protocolId: protocol.id, walletId: wallet.id,
receiverId: userId receiverId: userId
} }
}), }),
@ -346,26 +346,22 @@ export async function retryPaidAction (actionType, args, incomingContext) {
invoiceId: failedInvoice.id invoiceId: failedInvoice.id
}, },
include: { include: {
protocol: { wallet: true
include: {
wallet: true
}
}
} }
}) })
if (invoiceForward) { if (invoiceForward) {
// this is a wrapped invoice, we need to retry it with receiver fallbacks // this is a wrapped invoice, we need to retry it with receiver fallbacks
try { try {
const { userId } = invoiceForward.protocol.wallet const { userId } = invoiceForward.wallet
// this will return an invoice from the first receiver wallet that didn't fail yet and throw if none is available // this will return an invoice from the first receiver wallet that didn't fail yet and throw if none is available
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, protocol, maxFee } = await createWrappedInvoice(userId, { const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: failedInvoice.msatsRequested, msats: failedInvoice.msatsRequested,
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext), feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
description: await action.describe?.(actionArgs, retryContext), description: await action.describe?.(actionArgs, retryContext),
expiry: INVOICE_EXPIRE_SECS expiry: INVOICE_EXPIRE_SECS
}, retryContext) }, retryContext)
invoiceArgs = { bolt11, wrappedBolt11, protocol, maxFee } invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
} catch (err) { } catch (err) {
console.log('failed to retry wrapped invoice, falling back to SN:', err) console.log('failed to retry wrapped invoice, falling back to SN:', err)
} }
@ -433,7 +429,7 @@ async function createSNInvoice (actionType, args, context) {
async function createDbInvoice (actionType, args, context) { async function createDbInvoice (actionType, args, context) {
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context
const { bolt11, wrappedBolt11, preimage, protocol, maxFee } = invoiceArgs const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs
const db = tx ?? models const db = tx ?? models
@ -472,9 +468,9 @@ async function createDbInvoice (actionType, args, context) {
invoice: { invoice: {
create: invoiceData create: invoiceData
}, },
protocol: { wallet: {
connect: { connect: {
id: protocol.id id: wallet.id
} }
} }
} }

View File

@ -1,9 +1,8 @@
import { ANON_ITEM_SPAM_INTERVAL, ANON_FEE_MULTIPLIER, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers, notifyThreadSubscribers } from '@/lib/webPush' import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers, notifyThreadSubscribers } from '@/lib/webPush'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item' import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { msatsToSats, satsToMsats } from '@/lib/format' import { msatsToSats, satsToMsats } from '@/lib/format'
import { GqlInputError } from '@/lib/error' import { GqlInputError } from '@/lib/error'
import { throwOnExpiredUploads } from '@/api/resolvers/upload'
export const anonable = true export const anonable = true
@ -39,12 +38,12 @@ export async function getBaseCost ({ models, bio, parentId, subName }) {
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) { export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
const baseCost = await getBaseCost({ models, bio, parentId, subName }) const baseCost = await getBaseCost({ models, bio, parentId, subName })
// cost = baseCost * 10^num_items_in_10m * 10 (ANON_FEE_MULTIPLIER constant) or 1 (user) + upload fees + boost // cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + upload fees + boost
const [{ cost }] = await models.$queryRaw` const [{ cost }] = await models.$queryRaw`
SELECT ${baseCost}::INTEGER SELECT ${baseCost}::INTEGER
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? USER_ID.anon}::INTEGER, * POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? USER_ID.anon}::INTEGER,
${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL)) ${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
* ${me ? 1 : ANON_FEE_MULTIPLIER}::INTEGER * ${me ? 1 : 100}::INTEGER
+ (SELECT "nUnpaid" * "uploadFeesMsats" + (SELECT "nUnpaid" * "uploadFeesMsats"
FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[])) FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ ${satsToMsats(boost)}::INTEGER as cost` + ${satsToMsats(boost)}::INTEGER as cost`
@ -62,7 +61,15 @@ export async function perform (args, context) {
const { tx, me, cost } = context const { tx, me, cost } = context
const boostMsats = satsToMsats(boost) const boostMsats = satsToMsats(boost)
await throwOnExpiredUploads(uploadIds, { tx }) const deletedUploads = []
for (const uploadId of uploadIds) {
if (!await tx.upload.findUnique({ where: { id: uploadId } })) {
deletedUploads.push(uploadId)
}
}
if (deletedUploads.length > 0) {
throw new Error(`upload(s) ${deletedUploads.join(', ')} are expired, consider reuploading.`)
}
let invoiceData = {} let invoiceData = {}
if (invoiceId) { if (invoiceId) {

View File

@ -1,5 +1,5 @@
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload' import { uploadFees } from '../resolvers/upload'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item' import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { notifyItemMention, notifyMention } from '@/lib/webPush' import { notifyItemMention, notifyMention } from '@/lib/webPush'
import { satsToMsats } from '@/lib/format' import { satsToMsats } from '@/lib/format'
@ -17,7 +17,7 @@ export async function getCost ({ id, boost = 0, uploadIds, bio }, { me, models }
// or more boost // or more boost
const old = await models.item.findUnique({ where: { id: parseInt(id) } }) const old = await models.item.findUnique({ where: { id: parseInt(id) } })
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me }) const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
const cost = totalFeesMsats + satsToMsats(boost - old.boost) const cost = BigInt(totalFeesMsats) + satsToMsats(boost - old.boost)
if (cost > 0 && old.invoiceActionState && old.invoiceActionState !== 'PAID') { if (cost > 0 && old.invoiceActionState && old.invoiceActionState !== 'PAID') {
throw new Error('creation invoice not paid') throw new Error('creation invoice not paid')
@ -60,7 +60,6 @@ export async function perform (args, context) {
const itemMentions = await getItemMentions(args, context) const itemMentions = await getItemMentions(args, context)
const itemUploads = uploadIds.map(id => ({ uploadId: id })) const itemUploads = uploadIds.map(id => ({ uploadId: id }))
await throwOnExpiredUploads(uploadIds, { tx })
await tx.upload.updateMany({ await tx.upload.updateMany({
where: { id: { in: uploadIds } }, where: { id: { in: uploadIds } },
data: { paid: true } data: { paid: true }
@ -164,8 +163,7 @@ export async function nonCriticalSideEffects ({ invoice, id }, { models }) {
where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) }, where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) },
include: { include: {
mentions: true, mentions: true,
itemReferrers: { include: { refereeItem: true } }, itemReferrers: { include: { refereeItem: true } }
user: true
} }
}) })
// compare timestamps to only notify if mention or item referral was just created to avoid duplicates on edits // compare timestamps to only notify if mention or item referral was just created to avoid duplicates on edits

View File

@ -1,5 +1,5 @@
import { PAID_ACTION_PAYMENT_METHODS, PROXY_RECEIVE_FEE_PERCENT } from '@/lib/constants' import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { toPositiveBigInt, numWithUnits, msatsToSats } from '@/lib/format' import { toPositiveBigInt, numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
import { notifyDeposit } from '@/lib/webPush' import { notifyDeposit } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server' import { getInvoiceableWallets } from '@/wallets/server'
@ -16,20 +16,22 @@ export async function getCost ({ msats }) {
export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) { export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) {
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && !me?.directReceive) return null
// don't fallback to direct if proxy is enabled to always hide stacker's node pubkey
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && me?.proxyReceive) return null
const wallets = await getInvoiceableWallets(me.id, { models }) const wallets = await getInvoiceableWallets(me.id, { models })
if (wallets.length === 0) { if (wallets.length === 0) {
return null return null
} }
if (cost < satsToMsats(me.receiveCreditsBelowSats)) {
return null
}
return me.id return me.id
} }
export async function getSybilFeePercent () { export async function getSybilFeePercent () {
return PROXY_RECEIVE_FEE_PERCENT return 10n
} }
export async function perform ({ export async function perform ({
@ -56,6 +58,24 @@ export async function describe ({ description }, { me, cost, paymentMethod, sybi
return description ?? `SN: ${me?.name ?? ''} receives ${numWithUnits(msatsToSats(cost - fee))}` return description ?? `SN: ${me?.name ?? ''} receives ${numWithUnits(msatsToSats(cost - fee))}`
} }
export async function onPaid ({ invoice }, { tx }) {
if (!invoice) {
throw new Error('invoice is required')
}
// P2P lnurlp does not need to update the user's balance
if (invoice?.invoiceForward) return
await tx.user.update({
where: { id: invoice.userId },
data: {
mcredits: {
increment: invoice.msatsReceived
}
}
})
}
export async function nonCriticalSideEffects ({ invoice }, { models }) { export async function nonCriticalSideEffects ({ invoice }, { models }) {
await notifyDeposit(invoice.userId, invoice) await notifyDeposit(invoice.userId, invoice)
await models.$executeRaw` await models.$executeRaw`

View File

@ -2,7 +2,6 @@ import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/consta
import { satsToMsats } from '@/lib/format' import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory' import { nextBilling } from '@/lib/territory'
import { initialTrust } from './lib/territory' import { initialTrust } from './lib/territory'
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
export const anonable = false export const anonable = false
@ -12,9 +11,8 @@ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
] ]
export async function getCost ({ billingType, uploadIds }, { models, me }) { export async function getCost ({ billingType }) {
const { totalFees } = await uploadFees(uploadIds, { models, me }) return satsToMsats(TERRITORY_PERIOD_COST(billingType))
return satsToMsats(BigInt(TERRITORY_PERIOD_COST(billingType)) + totalFees)
} }
export async function perform ({ invoiceId, ...data }, { me, cost, tx }) { export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
@ -23,19 +21,6 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
const billedLastAt = new Date() const billedLastAt = new Date()
const billPaidUntil = nextBilling(billedLastAt, billingType) const billPaidUntil = nextBilling(billedLastAt, billingType)
await throwOnExpiredUploads(data.uploadIds, { tx })
if (data.uploadIds.length > 0) {
await tx.upload.updateMany({
where: {
id: { in: data.uploadIds }
},
data: {
paid: true
}
})
}
delete data.uploadIds
const sub = await tx.sub.create({ const sub = await tx.sub.create({
data: { data: {
...data, ...data,

View File

@ -36,15 +36,8 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
data.userId = me.id data.userId = me.id
if (sub.userId !== me.id) { if (sub.userId !== me.id) {
try { await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } })
// XXX this will throw if this transfer has already happened await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } })
// 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({ await tx.subAct.create({
@ -85,16 +78,9 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
} }
}) })
const trust = initialTrust({ name: updatedSub.name, userId: updatedSub.userId }) await tx.userSubTrust.createMany({
for (const t of trust) { data: initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
await tx.userSubTrust.upsert({ })
where: {
userId_subName: { userId: t.userId, subName: t.subName }
},
update: t,
create: t
})
}
return updatedSub return updatedSub
} }

View File

@ -2,7 +2,6 @@ import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/consta
import { satsToMsats } from '@/lib/format' import { satsToMsats } from '@/lib/format'
import { proratedBillingCost } from '@/lib/territory' import { proratedBillingCost } from '@/lib/territory'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
export const anonable = false export const anonable = false
@ -12,16 +11,18 @@ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
] ]
export async function getCost ({ oldName, billingType, uploadIds }, { models, me }) { export async function getCost ({ oldName, billingType }, { models }) {
const oldSub = await models.sub.findUnique({ const oldSub = await models.sub.findUnique({
where: { where: {
name: oldName name: oldName
} }
}) })
const { totalFees } = await uploadFees(uploadIds, { models, me }) const cost = proratedBillingCost(oldSub, billingType)
if (!cost) {
return 0n
}
const cost = BigInt(proratedBillingCost(oldSub, billingType)) + totalFees
return satsToMsats(cost) return satsToMsats(cost)
} }
@ -62,19 +63,6 @@ export async function perform ({ oldName, invoiceId, ...data }, { me, cost, tx }
}) })
} }
await throwOnExpiredUploads(data.uploadIds, { tx })
if (data.uploadIds.length > 0) {
await tx.upload.updateMany({
where: {
id: { in: data.uploadIds }
},
data: {
paid: true
}
})
}
delete data.uploadIds
return await tx.sub.update({ return await tx.sub.update({
data, data,
where: { where: {

View File

@ -39,11 +39,11 @@ export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models,
return null return null
} }
const protocols = await getInvoiceableWallets(item.userId, { models }) const wallets = await getInvoiceableWallets(item.userId, { models })
// request peer invoice if they have an attached wallet and have not forwarded the item // request peer invoice if they have an attached wallet and have not forwarded the item
// and the receiver doesn't want to receive credits // and the receiver doesn't want to receive credits
if (protocols.length > 0 && if (wallets.length > 0 &&
item.itemForwards.length === 0 && item.itemForwards.length === 0 &&
sats >= item.user.receiveCreditsBelowSats) { sats >= item.user.receiveCreditsBelowSats) {
return item.userId return item.userId
@ -151,7 +151,7 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking // NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
await tx.$queryRaw` await tx.$queryRaw`
WITH territory AS ( WITH territory AS (
SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName" SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
FROM "Item" i FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId" LEFT JOIN "Item" r ON r.id = i."rootId"
WHERE i.id = ${itemAct.itemId}::INTEGER WHERE i.id = ${itemAct.itemId}::INTEGER

View File

@ -6,9 +6,9 @@ import { parsePaymentRequest, payViaPaymentRequest } from 'ln-service'
// paying actions are completely distinct from paid actions // paying actions are completely distinct from paid actions
// and there's only one paying action: send // and there's only one paying action: send
// ... still we want the api to at least be similar // ... still we want the api to at least be similar
export default async function performPayingAction ({ bolt11, maxFee, protocolId }, { me, models, lnd }) { export default async function performPayingAction ({ bolt11, maxFee, walletId }, { me, models, lnd }) {
try { try {
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, protocolId) console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, walletId)
if (!me) { if (!me) {
throw new Error('You must be logged in to perform this action') throw new Error('You must be logged in to perform this action')
@ -34,8 +34,8 @@ export default async function performPayingAction ({ bolt11, maxFee, protocolId
msatsPaying: toPositiveBigInt(decoded.mtokens), msatsPaying: toPositiveBigInt(decoded.mtokens),
msatsFeePaying: satsToMsats(maxFee), msatsFeePaying: satsToMsats(maxFee),
userId: me.id, userId: me.id,
protocolId, walletId,
autoWithdraw: !!protocolId autoWithdraw: !!walletId
} }
}) })
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })

View File

@ -1,8 +1,7 @@
import user from './user' import user from './user'
import message from './message' import message from './message'
import item from './item' import item from './item'
import walletV1 from './wallet' import wallet from './wallet'
import walletV2 from '@/wallets/server/resolvers'
import lnurl from './lnurl' import lnurl from './lnurl'
import notifications from './notifications' import notifications from './notifications'
import invite from './invite' import invite from './invite'
@ -20,6 +19,7 @@ import chainFee from './chainFee'
import { GraphQLScalarType, Kind } from 'graphql' import { GraphQLScalarType, Kind } from 'graphql'
import { createIntScalar } from 'graphql-scalar' import { createIntScalar } from 'graphql-scalar'
import paidAction from './paidAction' import paidAction from './paidAction'
import vault from './vault'
const date = new GraphQLScalarType({ const date = new GraphQLScalarType({
name: 'Date', name: 'Date',
@ -54,6 +54,6 @@ const limit = createIntScalar({
maximum: 1000 maximum: 1000
}) })
export default [user, item, message, walletV1, walletV2, lnurl, notifications, invite, sub, export default [user, item, message, wallet, lnurl, notifications, invite, sub,
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee, upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction] { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]

View File

@ -15,6 +15,7 @@ import {
FULL_COMMENTS_THRESHOLD FULL_COMMENTS_THRESHOLD
} from '@/lib/constants' } from '@/lib/constants'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts'
import uu from 'url-unshort' import uu from 'url-unshort'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, validateSchema } from '@/lib/validate' import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, validateSchema } from '@/lib/validate'
import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item' import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
@ -25,15 +26,11 @@ import assertApiKeyNotPermitted from './apiKey'
import performPaidAction from '../paidAction' import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error' import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { verifyHmac } from './wallet' import { verifyHmac } from './wallet'
import { parse } from 'tldts'
import { shuffleArray } from '@/lib/rand'
function commentsOrderByClause (me, models, sort) { function commentsOrderByClause (me, models, sort) {
const sharedSortsArray = [] const sharedSortsArray = []
sharedSortsArray.push('("Item"."pinId" IS NOT NULL) DESC') sharedSortsArray.push('("Item"."pinId" IS NOT NULL) DESC')
sharedSortsArray.push('("Item"."deletedAt" IS NULL) DESC') sharedSortsArray.push('("Item"."deletedAt" IS NULL) DESC')
// outlawed items should be at the bottom
sharedSortsArray.push(`NOT ("Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD} OR "Item".outlawed) DESC`)
const sharedSorts = sharedSortsArray.join(', ') const sharedSorts = sharedSortsArray.join(', ')
if (sort === 'recent') { if (sort === 'recent') {
@ -512,7 +509,7 @@ export default {
${whereClause( ${whereClause(
'"parentId" IS NULL', '"parentId" IS NULL',
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
activeOrMine(me), '"Item"."status" = \'ACTIVE\'',
'created_at <= $1', 'created_at <= $1',
'"pinId" IS NULL', '"pinId" IS NULL',
subClause(sub, 4) subClause(sub, 4)
@ -595,13 +592,7 @@ export default {
const response = await fetch(ensureProtocol(url), { redirect: 'follow' }) const response = await fetch(ensureProtocol(url), { redirect: 'follow' })
const html = await response.text() const html = await response.text()
const doc = domino.createWindow(html).document const doc = domino.createWindow(html).document
const titleRuleSet = { const metadata = getMetadata(doc, url, { title: metadataRuleSets.title, publicationDate: publicationDateRuleSet })
rules: [
['h1 > yt-formatted-string.ytd-watch-metadata', el => el.getAttribute('title')],
...metadataRuleSets.title.rules
]
}
const metadata = getMetadata(doc, url, { title: titleRuleSet, publicationDate: publicationDateRuleSet })
const dateHint = ` (${metadata.publicationDate?.getFullYear()})` const dateHint = ` (${metadata.publicationDate?.getFullYear()})`
const moreThanOneYearAgo = metadata.publicationDate && metadata.publicationDate < datePivot(new Date(), { years: -1 }) const moreThanOneYearAgo = metadata.publicationDate && metadata.publicationDate < datePivot(new Date(), { years: -1 })
@ -622,6 +613,7 @@ export default {
const urlObj = new URL(ensureProtocol(url)) const urlObj = new URL(ensureProtocol(url))
let { hostname, pathname } = urlObj let { hostname, pathname } = urlObj
// remove subdomain from hostname
const parseResult = parse(urlObj.hostname) const parseResult = parse(urlObj.hostname)
if (parseResult?.subdomain?.length > 0) { if (parseResult?.subdomain?.length > 0) {
hostname = hostname.replace(`${parseResult.subdomain}.`, '') hostname = hostname.replace(`${parseResult.subdomain}.`, '')
@ -647,9 +639,6 @@ export default {
} else if (urlObj.hostname === 'yewtu.be') { } else if (urlObj.hostname === 'yewtu.be') {
const matches = url.match(/(https?:\/\/)?yewtu\.be.*(v=|embed\/)(?<id>[_0-9a-z-]+)/i) const matches = url.match(/(https?:\/\/)?yewtu\.be.*(v=|embed\/)(?<id>[_0-9a-z-]+)/i)
similar = `^(http(s)?:\\/\\/)?yewtu\\.be\\/(watch\\?v\\=|embed\\/)${matches?.groups?.id}&?` similar = `^(http(s)?:\\/\\/)?yewtu\\.be\\/(watch\\?v\\=|embed\\/)${matches?.groups?.id}&?`
} else {
// only allow ending of mismatching search params
similar += '(?:\\?.*)?$'
} }
return await itemQueryWithMeta({ return await itemQueryWithMeta({
@ -658,7 +647,7 @@ export default {
query: ` query: `
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
WHERE url ~* $1 AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID') WHERE url ~* $1
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 3` LIMIT 3`
}, similar) }, similar)
@ -705,11 +694,7 @@ export default {
status: 'ACTIVE', status: 'ACTIVE',
deletedAt: null, deletedAt: null,
outlawed: false, outlawed: false,
parentId: null, parentId: null
OR: [
{ invoiceActionState: 'PAID' },
{ invoiceActionState: null }
]
} }
if (id) { if (id) {
where.id = { not: Number(id) } where.id = { not: Number(id) }
@ -739,24 +724,6 @@ export default {
homeMaxBoost: homeAgg._max.boost || 0, homeMaxBoost: homeAgg._max.boost || 0,
subMaxBoost: subAgg?._max.boost || 0 subMaxBoost: subAgg?._max.boost || 0
} }
},
newComments: async (parent, { rootId, after }, { models, me }) => {
const comments = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
-- comments can be nested, so we need to get all comments that are descendants of the root
${whereClause(
'"Item".path <@ (SELECT path FROM "Item" WHERE id = $1 AND "Item"."lastCommentAt" > $2)',
activeOrMine(me),
'"Item"."created_at" > $2'
)}
ORDER BY "Item"."created_at" ASC`
}, Number(rootId), after)
return { comments }
} }
}, },
@ -864,16 +831,8 @@ export default {
const data = { itemId: Number(id), userId: me.id } const data = { itemId: Number(id), userId: me.id }
const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } }) const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } })
if (old) { if (old) {
await models.$executeRaw` await models.threadSubscription.delete({ where: { userId_itemId: data } })
DELETE FROM "ThreadSubscription" ts } else await models.threadSubscription.create({ data })
USING "Item" i
WHERE ts."userId" = ${me.id}
AND i.path <@ (SELECT path FROM "Item" WHERE id = ${Number(id)})
AND ts."itemId" = i.id
`
} else {
await models.threadSubscription.create({ data })
}
return { id } return { id }
}, },
deleteItem: async (parent, { id }, { me, models }) => { deleteItem: async (parent, { id }, { me, models }) => {
@ -1189,8 +1148,7 @@ export default {
poll.meVoted = false poll.meVoted = false
} }
poll.randPollOptions = item?.randPollOptions poll.options = options
poll.options = poll.randPollOptions ? shuffleArray(options) : options
poll.count = options.reduce((t, o) => t + o.count, 0) poll.count = options.reduce((t, o) => t + o.count, 0)
return poll return poll
@ -1491,7 +1449,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
throw new GqlInputError('item can no longer be edited') throw new GqlInputError('item can no longer be edited')
} }
if (item.url && !isJob({ subName, ...item })) { if (item.url && !isJob(item)) {
item.url = ensureProtocol(item.url) item.url = ensureProtocol(item.url)
item.url = removeTracking(item.url) item.url = removeTracking(item.url)
} }
@ -1506,7 +1464,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
item = { subName, ...item } item = { subName, ...item }
item.forwardUsers = await getForwardUsers(models, forward) item.forwardUsers = await getForwardUsers(models, forward)
} }
item.uploadIds = uploadIdsFromText(item.text) item.uploadIds = uploadIdsFromText(item.text, { models })
// never change author of item // never change author of item
item.userId = old.userId item.userId = old.userId
@ -1525,7 +1483,7 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd
item.userId = me ? Number(me.id) : USER_ID.anon item.userId = me ? Number(me.id) : USER_ID.anon
item.forwardUsers = await getForwardUsers(models, forward) item.forwardUsers = await getForwardUsers(models, forward)
item.uploadIds = uploadIdsFromText(item.text) item.uploadIds = uploadIdsFromText(item.text, { models })
if (item.url && !isJob(item)) { if (item.url && !isJob(item)) {
item.url = ensureProtocol(item.url) item.url = ensureProtocol(item.url)

View File

@ -2,7 +2,7 @@ import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor'
import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item' import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item'
import { getInvoice, getWithdrawl } from './wallet' import { getInvoice, getWithdrawl } from './wallet'
import { pushSubscriptionSchema, validateSchema } from '@/lib/validate' import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
import { sendPushSubscriptionReply } from '@/lib/webPush' import { replyToSubscription } from '@/lib/webPush'
import { getSub } from './sub' import { getSub } from './sub'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error' import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants' import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
@ -316,36 +316,13 @@ export default {
if (meFull.noteCowboyHat) { if (meFull.noteCowboyHat) {
queries.push( queries.push(
`(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'CowboyHat' AS type `(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type
FROM "Streak" FROM "Streak"
WHERE "userId" = $1 WHERE "userId" = $1
AND updated_at < $2 AND updated_at < $2
AND type = 'COWBOY_HAT'
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT})` LIMIT ${LIMIT})`
) )
for (const type of ['HORSE', 'GUN']) {
const gqlType = type.charAt(0) + type.slice(1).toLowerCase()
queries.push(
`(SELECT id::text, "startedAt" AS "sortTime", 0 as "earnedSats", 'New${gqlType}' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at < $2
AND type = '${type}'::"StreakType"
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT id::text AS id, "endedAt" AS "sortTime", 0 as "earnedSats", 'Lost${gqlType}' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at < $2
AND "endedAt" IS NOT NULL
AND type = '${type}'::"StreakType"
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}
} }
queries.push( queries.push(
@ -439,7 +416,7 @@ export default {
console.log(`[webPush] created subscription for user ${me.id}: endpoint=${endpoint}`) console.log(`[webPush] created subscription for user ${me.id}: endpoint=${endpoint}`)
} }
await sendPushSubscriptionReply(dbPushSubscription) await replyToSubscription(dbPushSubscription.id, { title: 'Stacker News notifications are now active' })
return dbPushSubscription return dbPushSubscription
}, },
@ -484,13 +461,8 @@ export default {
}, },
TerritoryTransfer: { TerritoryTransfer: {
sub: async (n, args, { models, me }) => { sub: async (n, args, { models, me }) => {
const [sub] = await models.$queryRaw` const transfer = await models.territoryTransfer.findUnique({ where: { id: Number(n.id) }, include: { sub: true } })
SELECT "Sub".* return transfer.sub
FROM "TerritoryTransfer"
JOIN "Sub" ON "Sub"."name" = "TerritoryTransfer"."subName"
WHERE "TerritoryTransfer"."id" = ${Number(n.id)}`
return sub
} }
}, },
JobChanged: { JobChanged: {
@ -528,14 +500,23 @@ export default {
} }
} }
}, },
CowboyHat: { Streak: {
days: async (n, args, { models }) => { days: async (n, args, { models }) => {
const res = await models.$queryRaw` const res = await models.$queryRaw`
SELECT "endedAt"::date - "startedAt"::date AS days SELECT "endedAt" - "startedAt" AS days
FROM "Streak" FROM "Streak"
WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL
` `
return res.length ? res[0].days : null return res.length ? res[0].days : null
},
type: async (n, args, { models }) => {
const res = await models.$queryRaw`
SELECT "type"
FROM "Streak"
WHERE id = ${Number(n.id)}
`
return res.length ? res[0].type : null
} }
}, },
Earn: { Earn: {

View File

@ -1,5 +1,6 @@
import { amountSchema, validateSchema } from '@/lib/validate' import { amountSchema, validateSchema } from '@/lib/validate'
import { getAd, getItem } from './item' import { getAd, getItem } from './item'
import { topUsers } from './user'
import performPaidAction from '../paidAction' import performPaidAction from '../paidAction'
import { GqlInputError } from '@/lib/error' import { GqlInputError } from '@/lib/error'
@ -151,6 +152,13 @@ export default {
} }
}, },
Rewards: { Rewards: {
leaderboard: async (parent, args, { models, ...context }) => {
// get to and from using postgres because it's easier to do there
const [{ to, from }] = await models.$queryRaw`
SELECT date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago' as from,
(date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago') + interval '1 day - 1 second' as to`
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 500 }, { models, ...context })
},
total: async (parent, args, { models }) => { total: async (parent, args, { models }) => {
if (!parent.total) { if (!parent.total) {
return 0 return 0

View File

@ -1,7 +1,6 @@
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { whenToFrom } from '@/lib/time' import { whenToFrom } from '@/lib/time'
import { getItem, itemQueryWithMeta, SELECT } from './item' import { getItem, itemQueryWithMeta, SELECT } from './item'
import { parse } from 'tldts'
function queryParts (q) { function queryParts (q) {
const regex = /"([^"]*)"/gm const regex = /"([^"]*)"/gm
@ -254,17 +253,24 @@ export default {
// if search contains a url term, modify the query text // if search contains a url term, modify the query text
if (url) { if (url) {
let uri = url.slice(4) const uri = url.slice(4)
termQueries.push({ let uriObj
match_bool_prefix: { url: { query: uri, operator: 'and', boost: 1000 } } try {
}) uriObj = new URL(uri)
const parsed = parse(uri) } catch {
if (parsed?.subdomain?.length > 0) { try {
uri = uri.replace(`${parsed.subdomain}.`, '') uriObj = new URL(`https://${uri}`)
} catch {}
}
if (uriObj) {
termQueries.push({
wildcard: { url: `*${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}*` }
})
termQueries.push({
match: { text: `${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}` }
})
} }
termQueries.push({
wildcard: { url: { value: `*${uri}*` } }
})
} }
// if nym, items must contain nym // if nym, items must contain nym
@ -283,23 +289,25 @@ export default {
// if quoted phrases, items must contain entire phrase // if quoted phrases, items must contain entire phrase
for (const quote of quotes) { for (const quote of quotes) {
filters.push({
multi_match: {
query: quote,
fields: ['title.exact', 'text.exact'],
type: 'phrase'
}
})
termQueries.push({ termQueries.push({
multi_match: { multi_match: {
query: quote, query: quote,
fields: ['title.exact^10', 'text.exact'],
type: 'phrase', type: 'phrase',
boost: 1000 fields: ['title', 'text']
}
})
// force the search to include the quoted phrase
filters.push({
multi_match: {
query: quote,
type: 'phrase',
fields: ['title', 'text']
} }
}) })
} }
// functions for boosting search rank by recency or popularity
switch (sort) { switch (sort) {
case 'comments': case 'comments':
functions.push({ functions.push({
@ -387,24 +395,6 @@ export default {
fields: ['title^10', 'text'], fields: ['title^10', 'text'],
boost: 1000 boost: 1000
} }
},
// match on exact fields higher
{
multi_match: {
query,
type: 'best_fields',
fields: ['title.exact^10', 'text.exact'],
boost: 100
}
},
// exact phrase matches higher
{
multi_match: {
query,
fields: ['title.exact^10', 'text.exact'],
type: 'phrase',
boost: 10000
}
} }
] ]
@ -416,7 +406,6 @@ export default {
if (process.env.OPENSEARCH_MODEL_ID) { if (process.env.OPENSEARCH_MODEL_ID) {
osQuery = { osQuery = {
hybrid: { hybrid: {
pagination_depth: 50,
queries: [ queries: [
{ {
bool: { bool: {
@ -468,9 +457,7 @@ export default {
highlight: { highlight: {
fields: { fields: {
title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] }, title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
'title.exact': { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] }, text: { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] }
text: { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] },
'text.exact': { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] }
} }
} }
} }
@ -505,14 +492,8 @@ export default {
orderBy: 'ORDER BY rank ASC, msats DESC' orderBy: 'ORDER BY rank ASC, msats DESC'
})).map((item, i) => { })).map((item, i) => {
const e = sitems.body.hits.hits[i] const e = sitems.body.hits.hits[i]
item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title
// prefer the fuzzier highlight for title item.searchText = (e.highlight?.text && e.highlight.text.join(' ... ')) || undefined
item.searchTitle = e.highlight?.title?.[0] || e.highlight?.['title.exact']?.[0] || item.title
// prefer the exact highlight for text
const searchTextHighlight = e.highlight?.['text.exact'] || e.highlight?.text || []
item.searchText = searchTextHighlight?.join(' ... ')
return item return item
}) })

View File

@ -5,7 +5,6 @@ import { viewGroup } from './growth'
import { notifyTerritoryTransfer } from '@/lib/webPush' import { notifyTerritoryTransfer } from '@/lib/webPush'
import performPaidAction from '../paidAction' import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error' import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { uploadIdsFromText } from './upload'
export async function getSub (parent, { name }, { models, me }) { export async function getSub (parent, { name }, { models, me }) {
if (!name) return null if (!name) return null
@ -36,27 +35,6 @@ export async function getSub (parent, { name }, { models, me }) {
export default { export default {
Query: { Query: {
sub: getSub, sub: getSub,
subSuggestions: async (parent, { q, limit = 5 }, { models }) => {
let subs = []
if (q) {
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status = 'ACTIVE'
AND SIMILARITY(name, ${q}) > 0.1
ORDER BY SIMILARITY(name, ${q}) DESC
LIMIT ${limit}`
} else {
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status = 'ACTIVE'
ORDER BY name ASC
LIMIT ${limit}`
}
return subs
},
subs: async (parent, args, { models, me }) => { subs: async (parent, args, { models, me }) => {
if (me) { if (me) {
const currentUser = await models.user.findUnique({ where: { id: me.id } }) const currentUser = await models.user.findUnique({ where: { id: me.id } })
@ -128,7 +106,7 @@ export default {
subs subs
} }
}, },
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models, me }) => { userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models }) => {
if (!name) { if (!name) {
throw new GqlInputError('must supply user name') throw new GqlInputError('must supply user name')
} }
@ -151,56 +129,26 @@ export default {
} }
const subs = await models.$queryRawUnsafe(` const subs = await models.$queryRawUnsafe(`
SELECT "Sub".*, SELECT "Sub".*,
"Sub".created_at as "createdAt", "Sub".created_at as "createdAt",
COALESCE(floor(sum(msats_revenue)/1000), 0) as revenue, COALESCE(floor(sum(msats_revenue)/1000), 0) as revenue,
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked, COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
COALESCE(floor(sum(msats_spent)/1000), 0) as spent, COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts, COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments, COALESCE(sum(comments), 0) as ncomments
ss."userId" IS NOT NULL as "meSubscription", FROM ${viewGroup(range, 'sub_stats')}
ms."userId" IS NOT NULL as "meMuteSub" JOIN "Sub" on "Sub".name = u.sub_name
FROM ${viewGroup(range, 'sub_stats')} WHERE "Sub"."userId" = $3
JOIN "Sub" on "Sub".name = u.sub_name AND "Sub".status = 'ACTIVE'
LEFT JOIN "SubSubscription" ss ON ss."subName" = "Sub".name AND ss."userId" IS NOT DISTINCT FROM $4 GROUP BY "Sub".name
LEFT JOIN "MuteSub" ms ON ms."subName" = "Sub".name AND ms."userId" IS NOT DISTINCT FROM $4 ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
WHERE "Sub"."userId" = $3 OFFSET $4
AND "Sub".status = 'ACTIVE' LIMIT $5`, ...range, user.id, decodedCursor.offset, limit)
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 { return {
cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null, cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
subs subs
} }
},
mySubscribedSubs: async (parent, { cursor }, { models, me }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const decodedCursor = decodeCursor(cursor)
const subs = await models.$queryRaw`
SELECT "Sub".*,
"MuteSub"."userId" IS NOT NULL as "meMuteSub",
TRUE as "meSubscription"
FROM "SubSubscription"
JOIN "Sub" ON "SubSubscription"."subName" = "Sub".name
LEFT JOIN "MuteSub" ON "MuteSub"."subName" = "Sub".name AND "MuteSub"."userId" = ${me.id}
WHERE "SubSubscription"."userId" = ${me.id}
AND "Sub".status <> 'STOPPED'
ORDER BY "Sub".name ASC
OFFSET ${decodedCursor.offset}
LIMIT ${LIMIT}
`
return {
cursor: subs.length === LIMIT ? nextCursorEncoded(decodedCursor, LIMIT) : null,
subs
}
} }
}, },
Mutation: { Mutation: {
@ -211,8 +159,6 @@ export default {
await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } }) await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } })
data.uploadIds = uploadIdsFromText(data.desc)
if (data.oldName) { if (data.oldName) {
return await updateSub(parent, data, { me, models, lnd }) return await updateSub(parent, data, { me, models, lnd })
} else { } else {

View File

@ -6,17 +6,7 @@ import { msatsToSats } from '@/lib/format'
export default { export default {
Query: { Query: {
uploadFees: async (parent, { s3Keys }, { models, me }) => { uploadFees: async (parent, { s3Keys }, { models, me }) => {
const fees = await uploadFees(s3Keys, { models, me }) return uploadFees(s3Keys, { models, me })
// GraphQL doesn't support bigint
return {
totalFees: Number(fees.totalFees),
totalFeesMsats: Number(fees.totalFeesMsats),
uploadFees: Number(fees.uploadFees),
uploadFeesMsats: Number(fees.uploadFeesMsats),
nUnpaid: Number(fees.nUnpaid),
bytesUnpaid: Number(fees.bytesUnpaid),
bytes24h: Number(fees.bytes24h)
}
} }
}, },
Mutation: { Mutation: {
@ -64,36 +54,17 @@ export default {
} }
} }
export function uploadIdsFromText (text) { export function uploadIdsFromText (text, { models }) {
if (!text) return [] if (!text) return []
return [...new Set([...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])))] return [...new Set([...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])))]
} }
export async function uploadFees (s3Keys, { models, me }) { export async function uploadFees (s3Keys, { models, me }) {
const [{ // returns info object in this format:
bytes24h, // { bytes24h: int, bytesUnpaid: int, nUnpaid: int, uploadFeesMsats: BigInt }
bytesUnpaid, const [info] = await models.$queryRawUnsafe('SELECT * FROM upload_fees($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
nUnpaid, const uploadFees = msatsToSats(info.uploadFeesMsats)
uploadFeesMsats const totalFeesMsats = info.nUnpaid * Number(info.uploadFeesMsats)
}] = await models.$queryRaw`SELECT * FROM upload_fees(${me?.id ?? USER_ID.anon}::INTEGER, ${s3Keys}::INTEGER[])` const totalFees = msatsToSats(totalFeesMsats)
const uploadFees = BigInt(msatsToSats(uploadFeesMsats)) return { ...info, uploadFees, totalFees, totalFeesMsats }
const totalFeesMsats = BigInt(nUnpaid) * uploadFeesMsats
const totalFees = BigInt(msatsToSats(totalFeesMsats))
return { bytes24h, bytesUnpaid, nUnpaid, uploadFees, uploadFeesMsats, totalFees, totalFeesMsats }
}
export async function throwOnExpiredUploads (uploadIds, { tx }) {
if (uploadIds.length === 0) return
const existingUploads = await tx.upload.findMany({
where: { id: { in: uploadIds } },
select: { id: true }
})
const existingIds = new Set(existingUploads.map(upload => upload.id))
const deletedIds = uploadIds.filter(id => !existingIds.has(id))
if (deletedIds.length > 0) {
throw new Error(`upload(s) ${deletedIds.join(', ')} are expired, consider reuploading.`)
}
} }

View File

@ -11,7 +11,6 @@ import assertApiKeyNotPermitted from './apiKey'
import { hashEmail } from '@/lib/crypto' import { hashEmail } from '@/lib/crypto'
import { isMuted } from '@/lib/user' import { isMuted } from '@/lib/user'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { processCrop } from '@/worker/imgproxy'
const contributors = new Set() const contributors = new Set()
@ -728,18 +727,6 @@ export default {
return true return true
}, },
cropPhoto: async (parent, { photoId, cropData }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const croppedUrl = await processCrop({ photoId: Number(photoId), cropData })
if (!croppedUrl) {
throw new GqlInputError('can\'t crop photo')
}
return croppedUrl
},
setPhoto: async (parent, { photoId }, { me, models }) => { setPhoto: async (parent, { photoId }, { me, models }) => {
if (!me) { if (!me) {
throw new GqlAuthenticationError() throw new GqlAuthenticationError()
@ -911,22 +898,6 @@ export default {
await models.user.update({ where: { id: me.id }, data: { hideWelcomeBanner: true } }) await models.user.update({ where: { id: me.id }, data: { hideWelcomeBanner: true } })
return true return true
},
hideWalletRecvPrompt: async (parent, data, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.user.update({ where: { id: me.id }, data: { hideWalletRecvPrompt: true } })
return true
},
setDiagnostics: async (parent, { diagnostics }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.user.update({ where: { id: me.id }, data: { diagnostics } })
return diagnostics
} }
}, },
@ -1100,9 +1071,6 @@ export default {
return false return false
} }
return !!user.tipRandomMin && !!user.tipRandomMax return !!user.tipRandomMin && !!user.tipRandomMax
},
hideWalletRecvPrompt: async (user, args, { models }) => {
return user.hideWalletRecvPrompt || user.hasRecvWallet
} }
}, },
@ -1114,17 +1082,19 @@ export default {
return user.streak return user.streak
}, },
hasSendWallet: async (user, args, { models }) => { gunStreak: async (user, args, { models }) => {
if (user.hideCowboyHat) { if (user.hideCowboyHat) {
return false return null
} }
return user.hasSendWallet
return user.gunStreak
}, },
hasRecvWallet: async (user, args, { models }) => { horseStreak: async (user, args, { models }) => {
if (user.hideCowboyHat) { if (user.hideCowboyHat) {
return false return null
} }
return user.hasRecvWallet
return user.horseStreak
}, },
maxStreak: async (user, args, { models }) => { maxStreak: async (user, args, { models }) => {
if (user.hideCowboyHat) { if (user.hideCowboyHat) {
@ -1132,9 +1102,8 @@ export default {
} }
const [{ max }] = await models.$queryRaw` const [{ max }] = await models.$queryRaw`
SELECT MAX(COALESCE("endedAt"::date, (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt"::date) SELECT MAX(COALESCE("endedAt", (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt")
FROM "Streak" WHERE "userId" = ${user.id} FROM "Streak" WHERE "userId" = ${user.id}`
AND type = 'COWBOY_HAT'`
return max return max
}, },
isContributor: async (user, args, { me }) => { isContributor: async (user, args, { me }) => {

75
api/resolvers/vault.js Normal file
View File

@ -0,0 +1,75 @@
import { E_VAULT_KEY_EXISTS, GqlAuthenticationError, GqlInputError } from '@/lib/error'
export default {
Query: {
getVaultEntry: async (parent, { key }, { me, models }, info) => {
if (!me) throw new GqlAuthenticationError()
if (!key) throw new GqlInputError('must have key')
const k = await models.vault.findUnique({
where: {
key,
userId: me.id
}
})
return k
},
getVaultEntries: async (parent, { keysFilter }, { me, models }, info) => {
if (!me) throw new GqlAuthenticationError()
const entries = await models.vaultEntry.findMany({
where: {
userId: me.id,
key: keysFilter?.length
? {
in: keysFilter
}
: undefined
}
})
return entries
}
},
Mutation: {
// atomic vault migration
updateVaultKey: async (parent, { entries, hash }, { me, models }) => {
if (!me) throw new GqlAuthenticationError()
if (!hash) throw new GqlInputError('hash required')
const txs = []
const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } })
if (oldKeyHash) {
if (oldKeyHash !== hash) {
throw new GqlInputError('vault key already set', E_VAULT_KEY_EXISTS)
} else {
return true
}
} else {
txs.push(models.user.update({
where: { id: me.id },
data: { vaultKeyHash: hash }
}))
}
for (const entry of entries) {
txs.push(models.vaultEntry.update({
where: { userId_key: { userId: me.id, key: entry.key } },
data: { value: entry.value, iv: entry.iv }
}))
}
await models.$transaction(txs)
return true
},
clearVault: async (parent, args, { me, models }) => {
if (!me) throw new GqlAuthenticationError()
const txs = []
txs.push(models.user.update({
where: { id: me.id },
data: { vaultKeyHash: '' }
}))
txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } }))
await models.$transaction(txs)
return true
}
}
}

View File

@ -5,23 +5,90 @@ import {
import crypto, { timingSafeEqual } from 'crypto' import crypto, { timingSafeEqual } from 'crypto'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item' import { SELECT, itemQueryWithMeta } from './item'
import { msatsToSats, msatsToSatsDecimal } from '@/lib/format' import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
import { import {
USER_ID, INVOICE_RETENTION_DAYS, USER_ID, INVOICE_RETENTION_DAYS,
PAID_ACTION_PAYMENT_METHODS,
WALLET_CREATE_INVOICE_TIMEOUT_MS,
WALLET_RETRY_AFTER_MS, WALLET_RETRY_AFTER_MS,
WALLET_RETRY_BEFORE_MS, WALLET_RETRY_BEFORE_MS,
WALLET_MAX_RETRIES WALLET_MAX_RETRIES
} from '@/lib/constants' } from '@/lib/constants'
import { validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate' import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import assertGofacYourself from './ofac' import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import { finalizeHodlInvoice } from '@/worker/wallet' import { finalizeHodlInvoice } from '@/worker/wallet'
import walletDefs from '@/wallets/server'
import { generateResolverName, generateTypeDefName } from '@/wallets/graphql'
import { lnAddrOptions } from '@/lib/lnurl' import { lnAddrOptions } from '@/lib/lnurl'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { getNodeSockets } from '../lnd' import { getNodeSockets, getOurPubkey } from '../lnd'
import validateWallet from '@/wallets/validate'
import { canReceive, getWalletByType } from '@/wallets/common'
import performPaidAction from '../paidAction' import performPaidAction from '../paidAction'
import performPayingAction from '../payingAction' import performPayingAction from '../payingAction'
import { timeoutSignal, withTimeout } from '@/lib/time'
function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:')
for (const walletDef of walletDefs) {
const resolverName = generateResolverName(walletDef.walletField)
console.log(resolverName)
resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
console.log('resolving', resolverName, { settings, validateLightning, vaultEntries, ...data })
let existingVaultEntries
if (typeof vaultEntries === 'undefined' && data.id) {
// this mutation was sent from an unsynced client
// to pass validation, we need to add the existing vault entries for validation
// in case the client is removing the receiving config
existingVaultEntries = await models.vaultEntry.findMany({
where: {
walletId: Number(data.id)
}
})
}
const validData = await validateWallet(walletDef,
{ ...data, ...settings, vaultEntries: vaultEntries ?? existingVaultEntries },
{ serverSide: true })
if (validData) {
data && Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
settings && Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
}
// wallet in shape of db row
const wallet = {
field: walletDef.walletField,
type: walletDef.walletType,
userId: me?.id
}
const logger = walletLogger({ wallet, models })
return await upsertWallet({
wallet,
walletDef,
testCreateInvoice:
walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data })
? (data) => withTimeout(
walletDef.testCreateInvoice(data, {
logger,
signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS)
}),
WALLET_CREATE_INVOICE_TIMEOUT_MS)
: null
}, {
settings,
data,
vaultEntries
}, { logger, me, models })
}
}
console.groupEnd()
return resolvers
}
export async function getInvoice (parent, { id }, { me, models, lnd }) { export async function getInvoice (parent, { id }, { me, models, lnd }) {
const inv = await models.invoice.findUnique({ const inv = await models.invoice.findUnique({
@ -87,6 +154,54 @@ export function verifyHmac (hash, hmac) {
const resolvers = { const resolvers = {
Query: { Query: {
invoice: getInvoice, invoice: getInvoice,
wallet: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
return await models.wallet.findUnique({
where: {
userId: me.id,
id: Number(id)
},
include: {
vaultEntries: true
}
})
},
walletByType: async (parent, { type }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const wallet = await models.wallet.findFirst({
where: {
userId: me.id,
type
},
include: {
vaultEntries: true
}
})
return wallet
},
wallets: async (parent, args, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
return await models.wallet.findMany({
include: {
vaultEntries: true
},
where: {
userId: me.id
},
orderBy: {
priority: 'asc'
}
})
},
withdrawl: getWithdrawl, withdrawl: getWithdrawl,
direct: async (parent, { id }, { me, models }) => { direct: async (parent, { id }, { me, models }) => {
if (!me) { if (!me) {
@ -292,6 +407,67 @@ const resolvers = {
facts: history facts: history
} }
}, },
walletLogs: async (parent, { type, from, to, cursor }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
// we cursoring with the wallet logs on the client
// if we have from, don't use cursor
// regardless, store the state of the cursor for the next call
const decodedCursor = cursor ? decodeCursor(cursor) : { offset: 0, time: to ?? new Date() }
let logs = []
let nextCursor
if (from) {
logs = await models.walletLog.findMany({
where: {
userId: me.id,
wallet: type ?? undefined,
createdAt: {
gt: from ? new Date(Number(from)) : undefined,
lte: to ? new Date(Number(to)) : undefined
}
},
include: {
invoice: true,
withdrawal: true
},
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }
]
})
nextCursor = nextCursorEncoded(decodedCursor, logs.length)
} else {
logs = await models.walletLog.findMany({
where: {
userId: me.id,
wallet: type ?? undefined,
createdAt: {
lte: decodedCursor.time
}
},
include: {
invoice: true,
withdrawal: true
},
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }
],
take: LIMIT,
skip: decodedCursor.offset
})
nextCursor = logs.length === LIMIT ? nextCursorEncoded(decodedCursor, logs.length) : null
}
return {
cursor: nextCursor,
entries: logs
}
},
failedInvoices: async (parent, args, { me, models }) => { failedInvoices: async (parent, args, { me, models }) => {
if (!me) { if (!me) {
throw new GqlAuthenticationError() throw new GqlAuthenticationError()
@ -305,20 +481,38 @@ const resolvers = {
AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval
AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval
AND "paymentAttempt" < ${WALLET_MAX_RETRIES} AND "paymentAttempt" < ${WALLET_MAX_RETRIES}
AND (
"actionType" = 'ITEM_CREATE' OR
"actionType" = 'ZAP' OR
"actionType" = 'DOWN_ZAP' OR
"actionType" = 'POLL_VOTE' OR
"actionType" = 'BOOST'
)
ORDER BY id DESC` ORDER BY id DESC`
} }
}, },
Wallet: {
wallet: async (wallet) => {
return {
...wallet.wallet,
__resolveType: generateTypeDefName(wallet.type)
}
}
},
WalletDetails: {
__resolveType: wallet => wallet.__resolveType
},
InvoiceOrDirect: { InvoiceOrDirect: {
__resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType __resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType
}, },
Mutation: { Mutation: {
createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => {
await validateSchema(amountSchema, { amount })
await assertGofacYourself({ models, headers })
const { invoice, paymentMethod } = await performPaidAction('RECEIVE', {
msats: satsToMsats(amount)
}, { models, lnd, me })
return {
...invoice,
__resolveType:
paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT ? 'Direct' : 'Invoice'
}
},
createWithdrawl: createWithdrawal, createWithdrawl: createWithdrawal,
sendToLnAddr, sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => { cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => {
@ -379,6 +573,43 @@ const resolvers = {
return true return true
}, },
setWalletPriority: async (parent, { id, priority }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.wallet.update({ where: { userId: me.id, id: Number(id) }, data: { priority } })
return true
},
removeWallet: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const wallet = await models.wallet.findUnique({ where: { userId: me.id, id: Number(id) } })
if (!wallet) {
throw new GqlInputError('wallet not found')
}
const logger = walletLogger({ wallet, models })
await models.wallet.delete({ where: { userId: me.id, id: Number(id) } })
if (canReceive({ def: getWalletByType(wallet.type), config: wallet.wallet })) {
logger.info('details for receiving deleted')
}
return true
},
deleteWalletLogs: async (parent, { wallet }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.walletLog.deleteMany({ where: { userId: me.id, wallet } })
return true
},
buyCredits: async (parent, { credits }, { me, models, lnd }) => { buyCredits: async (parent, { credits }, { me, models, lnd }) => {
return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd }) return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd })
} }
@ -522,12 +753,223 @@ const resolvers = {
return item return item
}, },
sats: fact => msatsToSatsDecimal(fact.msats) sats: fact => msatsToSatsDecimal(fact.msats)
},
WalletLogEntry: {
context: async ({ level, context, invoice, withdrawal }, args, { models }) => {
const isError = ['error', 'warn'].includes(level.toLowerCase())
if (withdrawal) {
return {
...await logContextFromBolt11(withdrawal.bolt11),
...(withdrawal.preimage ? { preimage: withdrawal.preimage } : {}),
...(isError ? { max_fee: formatMsats(withdrawal.msatsFeePaying) } : {})
}
}
// XXX never return invoice as context because it might leak sensitive sender details
// if (invoice) { ... }
return context
}
} }
} }
export default resolvers export default injectResolvers(resolvers)
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, protocol, logger }) { const logContextFromBolt11 = async (bolt11) => {
const decoded = await parsePaymentRequest({ request: bolt11 })
return {
bolt11,
amount: formatMsats(decoded.mtokens),
payment_hash: decoded.id,
created_at: decoded.created_at,
expires_at: decoded.expires_at,
description: decoded.description
}
}
export const walletLogger = ({ wallet, models }) => {
// no-op logger if wallet is not provided
if (!wallet) {
return {
ok: () => {},
info: () => {},
error: () => {},
warn: () => {}
}
}
// server implementation of wallet logger interface on client
const log = (level) => async (message, ctx = {}) => {
try {
let { invoiceId, withdrawalId, ...context } = ctx
if (context.bolt11) {
// automatically populate context from bolt11 to avoid duplicating this code
context = {
...context,
...await logContextFromBolt11(context.bolt11)
}
}
await models.walletLog.create({
data: {
userId: wallet.userId,
wallet: wallet.type,
level,
message,
context,
invoiceId,
withdrawalId
}
})
} catch (err) {
console.error('error creating wallet log:', err)
}
}
return {
ok: (message, context) => log('SUCCESS')(message, context),
info: (message, context) => log('INFO')(message, context),
error: (message, context) => log('ERROR')(message, context),
warn: (message, context) => log('WARN')(message, context)
}
}
async function upsertWallet (
{ wallet, walletDef, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) {
if (!me) {
throw new GqlAuthenticationError()
}
assertApiKeyNotPermitted({ me })
if (testCreateInvoice) {
try {
await testCreateInvoice(data)
} catch (err) {
const message = 'failed to create test invoice: ' + (err.message || err.toString?.())
logger.error(message)
throw new GqlInputError(message)
}
}
const { id, enabled, priority, ...recvConfig } = data
const txs = []
if (id) {
const oldVaultEntries = await models.vaultEntry.findMany({ where: { userId: me.id, walletId: Number(id) } })
// createMany is the set difference of the new - old
// deleteMany is the set difference of the old - new
// updateMany is the intersection of the old and new
const difference = (a = [], b = [], key = 'key') => a.filter(x => !b.find(y => y[key] === x[key]))
const intersectionMerge = (a = [], b = [], key = 'key') => a.filter(x => b.find(y => y[key] === x[key]))
.map(x => ({ [key]: x[key], ...b.find(y => y[key] === x[key]) }))
txs.push(
models.wallet.update({
where: { id: Number(id), userId: me.id },
data: {
enabled,
priority,
// client only wallets have no receive config and thus don't have their own table
...(Object.keys(recvConfig).length > 0
? {
[wallet.field]: {
upsert: {
create: recvConfig,
update: recvConfig
}
}
}
: {}),
...(vaultEntries
? {
vaultEntries: {
deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({
userId: me.id, key
})),
create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({
key, iv, value, userId: me.id
})),
update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({
where: { userId_key: { userId: me.id, key } },
data: { value, iv }
}))
}
}
: {})
},
include: {
vaultEntries: true
}
})
)
} else {
txs.push(
models.wallet.create({
include: {
vaultEntries: true
},
data: {
enabled,
priority,
userId: me.id,
type: wallet.type,
// client only wallets have no receive config and thus don't have their own table
...(Object.keys(recvConfig).length > 0 ? { [wallet.field]: { create: recvConfig } } : {}),
...(vaultEntries
? {
vaultEntries: {
createMany: {
data: vaultEntries?.map(({ key, iv, value }) => ({ key, iv, value, userId: me.id }))
}
}
}
: {})
}
})
)
}
if (settings) {
txs.push(
models.user.update({
where: { id: me.id },
data: settings
})
)
}
if (canReceive({ def: walletDef, config: recvConfig })) {
txs.push(
models.walletLog.createMany({
data: {
userId: me.id,
wallet: wallet.type,
level: 'SUCCESS',
message: id ? 'details for receiving updated' : 'details for receiving saved'
}
}),
models.walletLog.create({
data: {
userId: me.id,
wallet: wallet.type,
level: enabled ? 'SUCCESS' : 'INFO',
message: enabled ? 'receiving enabled' : 'receiving disabled'
}
})
)
}
const [upsertedWallet] = await models.$transaction(txs)
return upsertedWallet
}
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, wallet, logger }) {
assertApiKeyNotPermitted({ me }) assertApiKeyNotPermitted({ me })
await validateSchema(withdrawlSchema, { invoice, maxFee }) await validateSchema(withdrawlSchema, { invoice, maxFee })
await assertGofacYourself({ models, headers }) await assertGofacYourself({ models, headers })
@ -577,10 +1019,10 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
throw new GqlInputError('SN cannot pay an invoice that SN is proxying') throw new GqlInputError('SN cannot pay an invoice that SN is proxying')
} }
return await performPayingAction({ bolt11: invoice, maxFee, protocolId: protocol?.id }, { me, models, lnd }) return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd })
} }
async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer }, export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
{ me, models, lnd, headers }) { { me, models, lnd, headers }) {
if (!me) { if (!me) {
throw new GqlAuthenticationError() throw new GqlAuthenticationError()
@ -598,9 +1040,11 @@ async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer }
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers }) return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
} }
async function fetchLnAddrInvoice ( export async function fetchLnAddrInvoice (
{ addr, amount, maxFee, comment, ...payer }, { addr, amount, maxFee, comment, ...payer },
{ me, models, lnd }) { {
me, models, lnd, autoWithdraw = false
}) {
const options = await lnAddrOptions(addr) const options = await lnAddrOptions(addr)
await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options) await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
@ -637,6 +1081,14 @@ async function fetchLnAddrInvoice (
// decode invoice // decode invoice
try { try {
const decoded = await parsePaymentRequest({ request: res.pr }) const decoded = await parsePaymentRequest({ request: res.pr })
const ourPubkey = await getOurPubkey({ lnd })
if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
// unset lnaddr so we don't trigger another withdrawal with same destination
await models.wallet.deleteMany({
where: { userId: me.id, type: 'LIGHTNING_ADDRESS' }
})
throw new Error('automated withdrawals to other stackers are not allowed')
}
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) { if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
throw new Error('invoice has incorrect amount') throw new Error('invoice has incorrect amount')
} }

17
api/slack/index.js Normal file
View File

@ -0,0 +1,17 @@
import { WebClient, LogLevel } from '@slack/web-api'
const slackClient = global.slackClient || (() => {
if (!process.env.SLACK_BOT_TOKEN && !process.env.SLACK_CHANNEL_ID) {
console.warn('SLACK_* env vars not set, skipping slack setup')
return null
}
console.log('initing slack client')
const client = new WebClient(process.env.SLACK_BOT_TOKEN, {
logLevel: LogLevel.INFO
})
return client
})()
if (process.env.NODE_ENV === 'development') global.slackClient = slackClient
export default slackClient

View File

@ -15,7 +15,7 @@ import { getServerSession } from 'next-auth/next'
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]' import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
import { NOFOLLOW_LIMIT } from '@/lib/constants' import { NOFOLLOW_LIMIT } from '@/lib/constants'
import { satsToMsats } from '@/lib/format' import { satsToMsats } from '@/lib/format'
import { MULTI_AUTH_ANON, MULTI_AUTH_POINTER } from '@/lib/auth' import { MULTI_AUTH_ANON, MULTI_AUTH_LIST } from '@/lib/auth'
export default async function getSSRApolloClient ({ req, res, me = null }) { export default async function getSSRApolloClient ({ req, res, me = null }) {
const session = req && await getServerSession(req, res, getAuthOptions(req)) const session = req && await getServerSession(req, res, getAuthOptions(req))
@ -156,7 +156,7 @@ export function getGetServerSideProps (
// required to redirect to /signup on page reload // required to redirect to /signup on page reload
// if we switched to anon and authentication is required // if we switched to anon and authentication is required
if (req.cookies[MULTI_AUTH_POINTER] === MULTI_AUTH_ANON) { if (req.cookies[MULTI_AUTH_LIST] === MULTI_AUTH_ANON) {
me = null me = null
} }

View File

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

View File

@ -11,7 +11,6 @@ export default gql`
auctionPosition(sub: String, id: ID, boost: Int): Int! auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions! boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int! itemRepetition(parentId: ID): Int!
newComments(rootId: ID, after: Date): Comments!
} }
type BoostPositions { type BoostPositions {
@ -58,7 +57,7 @@ export default gql`
text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction! text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction!
upsertPoll( upsertPoll(
id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date, id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date,
randPollOptions: Boolean, hash: String, hmac: String): ItemPaidAction! hash: String, hmac: String): ItemPaidAction!
updateNoteId(id: ID!, noteId: String!): Item! updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction! upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction!
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction! act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
@ -82,7 +81,6 @@ export default gql`
meInvoiceActionState: InvoiceActionState meInvoiceActionState: InvoiceActionState
count: Int! count: Int!
options: [PollOption!]! options: [PollOption!]!
randPollOptions: Boolean
} }
type Items { type Items {
@ -149,7 +147,6 @@ export default gql`
ncomments: Int! ncomments: Int!
nDirectComments: Int! nDirectComments: Int!
comments(sort: String, cursor: String): Comments! comments(sort: String, cursor: String): Comments!
injected: Boolean!
path: String path: String
position: Int position: Int
prior: Int prior: Int

View File

@ -75,6 +75,13 @@ export default gql`
tipComments: Int! tipComments: Int!
} }
type Streak {
id: ID!
sortTime: Date!
days: Int
type: String!
}
type Earn { type Earn {
id: ID! id: ID!
earnedSats: Int! earnedSats: Int!
@ -149,37 +156,11 @@ export default gql`
sortTime: Date! sortTime: Date!
} }
type CowboyHat {
id: ID!
sortTime: Date!
days: Int
}
type NewHorse {
id: ID!
sortTime: Date!
}
type LostHorse {
id: ID!
sortTime: Date!
}
type NewGun {
id: ID!
sortTime: Date!
}
type LostGun {
id: ID!
sortTime: Date!
}
union Notification = Reply | Votification | Mention union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral | Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
| FollowActivity | ForwardedVotification | Revenue | SubStatus | Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification | TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification
| ReferralReward | CowboyHat | NewHorse | LostHorse | NewGun | LostGun | ReferralReward
type Notifications { type Notifications {
lastChecked: Date lastChecked: Date

View File

@ -18,6 +18,7 @@ export default gql`
total: Int! total: Int!
time: Date! time: Date!
sources: [NameValue!]! sources: [NameValue!]!
leaderboard: UsersNullable
ad: Item ad: Item
} }

View File

@ -7,8 +7,6 @@ export default gql`
subs: [Sub!]! subs: [Sub!]!
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
mySubscribedSubs(cursor: String): Subs
subSuggestions(q: String!, limit: Limit): [Sub!]!
} }
type Subs { type Subs {

View File

@ -29,34 +29,21 @@ export default gql`
users: [User!]! users: [User!]!
} }
input CropData {
x: Float!
y: Float!
width: Float!
height: Float!
originalWidth: Int!
originalHeight: Int!
scale: Float!
}
extend type Mutation { extend type Mutation {
setName(name: String!): String setName(name: String!): String
setSettings(settings: SettingsInput!): User setSettings(settings: SettingsInput!): User
cropPhoto(photoId: ID!, cropData: CropData): String!
setPhoto(photoId: ID!): Int! setPhoto(photoId: ID!): Int!
upsertBio(text: String!): ItemPaidAction! upsertBio(text: String!): ItemPaidAction!
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
unlinkAuth(authType: String!): AuthMethods! unlinkAuth(authType: String!): AuthMethods!
linkUnverifiedEmail(email: String!): Boolean linkUnverifiedEmail(email: String!): Boolean
hideWelcomeBanner: Boolean hideWelcomeBanner: Boolean
hideWalletRecvPrompt: Boolean
subscribeUserPosts(id: ID): User subscribeUserPosts(id: ID): User
subscribeUserComments(id: ID): User subscribeUserComments(id: ID): User
toggleMute(id: ID): User toggleMute(id: ID): User
generateApiKey(id: ID!): String generateApiKey(id: ID!): String
deleteApiKey(id: ID!): User deleteApiKey(id: ID!): User
disableFreebies: Boolean disableFreebies: Boolean
setDiagnostics(diagnostics: Boolean!): Boolean
} }
type User { type User {
@ -87,6 +74,7 @@ export default gql`
input SettingsInput { input SettingsInput {
autoDropBolt11s: Boolean! autoDropBolt11s: Boolean!
diagnostics: Boolean!
noReferralLinks: Boolean! noReferralLinks: Boolean!
fiatCurrency: String! fiatCurrency: String!
satsFilter: Int! satsFilter: Int!
@ -124,6 +112,10 @@ export default gql`
zapUndos: Int zapUndos: Int
wildWestMode: Boolean! wildWestMode: Boolean!
withdrawMaxFeeDefault: Int! withdrawMaxFeeDefault: Int!
proxyReceive: Boolean
directReceive: Boolean
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
} }
type AuthMethods { type AuthMethods {
@ -149,18 +141,16 @@ export default gql`
""" """
lastCheckedJobs: String lastCheckedJobs: String
hideWelcomeBanner: Boolean! hideWelcomeBanner: Boolean!
hideWalletRecvPrompt: Boolean!
tipPopover: Boolean! tipPopover: Boolean!
upvotePopover: Boolean! upvotePopover: Boolean!
hasInvites: Boolean! hasInvites: Boolean!
apiKeyEnabled: Boolean! apiKeyEnabled: Boolean!
showPassphrase: Boolean!
diagnostics: Boolean!
""" """
mirrors SettingsInput mirrors SettingsInput
""" """
autoDropBolt11s: Boolean! autoDropBolt11s: Boolean!
diagnostics: Boolean!
noReferralLinks: Boolean! noReferralLinks: Boolean!
fiatCurrency: String! fiatCurrency: String!
satsFilter: Int! satsFilter: Int!
@ -201,9 +191,14 @@ export default gql`
wildWestMode: Boolean! wildWestMode: Boolean!
withdrawMaxFeeDefault: Int! withdrawMaxFeeDefault: Int!
autoWithdrawThreshold: Int autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Float
autoWithdrawMaxFeeTotal: Int
vaultKeyHash: String vaultKeyHash: String
vaultKeyHashUpdatedAt: Date
walletsUpdatedAt: Date walletsUpdatedAt: Date
proxyReceive: Boolean
directReceive: Boolean
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
} }
type UserOptional { type UserOptional {
@ -216,9 +211,6 @@ export default gql`
streak: Int streak: Int
gunStreak: Int gunStreak: Int
horseStreak: Int horseStreak: Int
hasSendWallet: Boolean
hasRecvWallet: Boolean
hideWalletRecvPrompt: Boolean
maxStreak: Int maxStreak: Int
isContributor: Boolean isContributor: Boolean
githubId: String githubId: String

29
api/typeDefs/vault.js Normal file
View File

@ -0,0 +1,29 @@
import { gql } from 'graphql-tag'
export default gql`
type VaultEntry {
id: ID!
key: String!
iv: String!
value: String!
createdAt: Date!
updatedAt: Date!
}
input VaultEntryInput {
key: String!
iv: String!
value: String!
walletId: ID
}
extend type Query {
getVaultEntry(key: String!): VaultEntry
getVaultEntries(keysFilter: [String!]): [VaultEntry!]!
}
extend type Mutation {
clearVault: Boolean
updateVaultKey(entries: [VaultEntryInput!]!, hash: String!): Boolean
}
`

View File

@ -1,8 +1,66 @@
import { gql } from 'graphql-tag' import { gql } from 'graphql-tag'
import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName } from '@/wallets/graphql'
import { isServerField } from '@/wallets/common'
import walletDefs from '@/wallets/server'
const shared = 'walletId: ID, templateName: ID, enabled: Boolean!' function injectTypeDefs (typeDefs) {
const injected = [rawTypeDefs(), mutationTypeDefs()]
return `${typeDefs}\n\n${injected.join('\n\n')}\n`
}
const typeDefs = gql` function mutationTypeDefs () {
console.group('injected GraphQL mutations:')
const typeDefs = walletDefs.map((w) => {
let args = 'id: ID, '
const serverFields = w.fields
.filter(isServerField)
.map(fieldToGqlArgOptional)
if (serverFields.length > 0) args += serverFields.join(', ') + ','
args += 'enabled: Boolean, priority: Int, vaultEntries: [VaultEntryInput!], settings: AutowithdrawSettings, validateLightning: Boolean'
const resolverName = generateResolverName(w.walletField)
const typeDef = `${resolverName}(${args}): Wallet`
console.log(typeDef)
return typeDef
})
console.groupEnd()
return `extend type Mutation {\n${typeDefs.join('\n')}\n}`
}
function rawTypeDefs () {
console.group('injected GraphQL type defs:')
const typeDefs = walletDefs.map((w) => {
let args = w.fields
.filter(isServerField)
.map(fieldToGqlArg)
.map(s => ' ' + s)
.join('\n')
if (!args) {
// add a placeholder arg so the type is not empty
args = ' _empty: Boolean'
}
const typeDefName = generateTypeDefName(w.walletType)
const typeDef = `type ${typeDefName} {\n${args}\n}`
console.log(typeDef)
return typeDef
})
let union = 'union WalletDetails = '
union += walletDefs.map((w) => {
const typeDefName = generateTypeDefName(w.walletType)
return typeDefName
}).join(' | ')
console.log(union)
console.groupEnd()
return typeDefs.join('\n\n') + union
}
const typeDefs = `
extend type Query { extend type Query {
invoice(id: ID!): Invoice! invoice(id: ID!): Invoice!
withdrawl(id: ID!): Withdrawl! withdrawl(id: ID!): Withdrawl!
@ -10,151 +68,23 @@ const typeDefs = gql`
numBolt11s: Int! numBolt11s: Int!
connectAddress: String! connectAddress: String!
walletHistory(cursor: String, inc: String): History walletHistory(cursor: String, inc: String): History
wallets: [WalletOrTemplate!]! wallets(includeReceivers: Boolean, includeSenders: Boolean, onlyEnabled: Boolean, prioritySort: String): [Wallet!]!
wallet(id: ID, name: String): WalletOrTemplate wallet(id: ID!): Wallet
walletSettings: WalletSettings! walletByType(type: String!): Wallet
walletLogs(protocolId: Int, cursor: String, debug: Boolean): WalletLogs! walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
failedInvoices: [Invoice!]! failedInvoices: [Invoice!]!
} }
extend type Mutation { extend type Mutation {
createInvoice(amount: Int!): InvoiceOrDirect!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl! createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl! sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
cancelInvoice(hash: String!, hmac: String, userCancel: Boolean): Invoice! cancelInvoice(hash: String!, hmac: String, userCancel: Boolean): Invoice!
dropBolt11(hash: String!): Boolean dropBolt11(hash: String!): Boolean
buyCredits(credits: Int!): BuyCreditsPaidAction!
# upserts
upsertWalletSendLNbits(
${shared},
url: String!,
apiKey: VaultEntryInput!
): WalletSendLNbits!
upsertWalletRecvLNbits(
${shared},
url: String!,
apiKey: String!
): WalletRecvLNbits!
upsertWalletSendPhoenixd(
${shared},
url: String!,
apiKey: VaultEntryInput!
): WalletSendPhoenixd!
upsertWalletRecvPhoenixd(
${shared},
url: String!,
apiKey: String!
): WalletRecvPhoenixd!
upsertWalletSendBlink(
${shared},
currency: VaultEntryInput!,
apiKey: VaultEntryInput!
): WalletSendBlink!
upsertWalletRecvBlink(
${shared},
currency: String!,
apiKey: String!
): WalletRecvBlink!
upsertWalletRecvLightningAddress(
${shared},
address: String!
): WalletRecvLightningAddress!
upsertWalletSendNWC(
${shared},
url: VaultEntryInput!
): WalletSendNWC!
upsertWalletRecvNWC(
${shared},
url: String!
): WalletRecvNWC!
upsertWalletRecvCLNRest(
${shared},
socket: String!,
rune: String!,
cert: String
): WalletRecvCLNRest!
upsertWalletRecvLNDGRPC(
${shared},
socket: String!,
macaroon: String!,
cert: String
): WalletRecvLNDGRPC!
upsertWalletSendLNC(
${shared},
pairingPhrase: VaultEntryInput!,
localKey: VaultEntryInput!,
remoteKey: VaultEntryInput!,
serverHost: VaultEntryInput!
): WalletSendLNC!
upsertWalletSendWebLN(
${shared}
): WalletSendWebLN!
# tests
testWalletRecvNWC(
url: String!
): Boolean!
testWalletRecvLightningAddress(
address: String!
): Boolean!
testWalletRecvCLNRest(
socket: String!,
rune: String!,
cert: String
): Boolean!
testWalletRecvLNDGRPC(
socket: String!,
macaroon: String!,
cert: String
): Boolean!
testWalletRecvPhoenixd(
url: String!
apiKey: String!
): Boolean!
testWalletRecvLNbits(
url: String!
apiKey: String!
): Boolean!
testWalletRecvBlink(
currency: String!
apiKey: String!
): Boolean!
# delete
removeWallet(id: ID!): Boolean removeWallet(id: ID!): Boolean
removeWalletProtocol(id: ID!): Boolean deleteWalletLogs(wallet: String): Boolean
setWalletPriority(id: ID!, priority: Int!): Boolean
# crypto buyCredits(credits: Int!): BuyCreditsPaidAction!
updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean
updateKeyHash(keyHash: String!): Boolean
resetWallets(newKeyHash: String!): Boolean
disablePassphraseExport: Boolean
# settings
setWalletSettings(settings: WalletSettingsInput!): Boolean
setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean
# logs
addWalletLog(protocolId: Int, level: String!, message: String!, timestamp: Date!, invoiceId: Int): Boolean
deleteWalletLogs(protocolId: Int, debug: Boolean): Boolean
} }
type BuyCreditsResult { type BuyCreditsResult {
@ -165,155 +95,15 @@ const typeDefs = gql`
id: ID! id: ID!
} }
union WalletOrTemplate = Wallet | WalletTemplate
enum WalletStatus {
OK
WARNING
ERROR
DISABLED
}
type Wallet { type Wallet {
id: ID! id: ID!
name: String! createdAt: Date!
priority: Int! updatedAt: Date!
template: WalletTemplate! type: String!
protocols: [WalletProtocol!]!
send: WalletStatus!
receive: WalletStatus!
}
type WalletTemplate {
name: ID!
protocols: [WalletProtocolTemplate!]!
send: WalletStatus!
receive: WalletStatus!
}
type WalletProtocol {
id: ID!
name: String!
send: Boolean!
enabled: Boolean! enabled: Boolean!
config: WalletProtocolConfig! priority: Int!
status: WalletStatus! wallet: WalletDetails!
} vaultEntries: [VaultEntry!]!
type WalletProtocolTemplate {
id: ID!
name: String!
send: Boolean!
}
union WalletProtocolConfig =
| WalletSendNWC
| WalletSendLNbits
| WalletSendPhoenixd
| WalletSendBlink
| WalletSendWebLN
| WalletSendLNC
| WalletRecvNWC
| WalletRecvLNbits
| WalletRecvPhoenixd
| WalletRecvBlink
| WalletRecvLightningAddress
| WalletRecvCLNRest
| WalletRecvLNDGRPC
type WalletSettings {
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Float
autoWithdrawMaxFeeTotal: Int
proxyReceive: Boolean!
}
input WalletSettingsInput {
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
autoWithdrawThreshold: Int!
autoWithdrawMaxFeePercent: Float!
autoWithdrawMaxFeeTotal: Int!
proxyReceive: Boolean!
}
type WalletSendNWC {
id: ID!
url: VaultEntry!
}
type WalletSendLNbits {
id: ID!
url: String!
apiKey: VaultEntry!
}
type WalletSendPhoenixd {
id: ID!
url: String!
apiKey: VaultEntry!
}
type WalletSendBlink {
id: ID!
currency: VaultEntry!
apiKey: VaultEntry!
}
type WalletSendWebLN {
id: ID!
}
type WalletSendLNC {
id: ID!
pairingPhrase: VaultEntry!
localKey: VaultEntry!
remoteKey: VaultEntry!
serverHost: VaultEntry!
}
type WalletRecvNWC {
id: ID!
url: String!
}
type WalletRecvLNbits {
id: ID!
url: String!
apiKey: String!
}
type WalletRecvPhoenixd {
id: ID!
url: String!
apiKey: String!
}
type WalletRecvBlink {
id: ID!
currency: String!
apiKey: String!
}
type WalletRecvLightningAddress {
id: ID!
address: String!
}
type WalletRecvCLNRest {
id: ID!
socket: String!
rune: String!
cert: String
}
type WalletRecvLNDGRPC {
id: ID!
socket: String!
macaroon: String!
cert: String
} }
input AutowithdrawSettings { input AutowithdrawSettings {
@ -322,22 +112,6 @@ const typeDefs = gql`
autoWithdrawMaxFeeTotal: Int! autoWithdrawMaxFeeTotal: Int!
} }
input WalletEncryptionUpdate {
id: ID!
protocols: [WalletEncryptionUpdateProtocol!]!
}
input WalletEncryptionUpdateProtocol {
name: String!
send: Boolean!
config: JSONObject!
}
input WalletPriorityUpdate {
id: ID!
priority: Int!
}
type Invoice implements InvoiceOrDirect { type Invoice implements InvoiceOrDirect {
id: ID! id: ID!
createdAt: Date! createdAt: Date!
@ -412,7 +186,7 @@ const typeDefs = gql`
cursor: String cursor: String
} }
type WalletLogs { type WalletLog {
entries: [WalletLogEntry!]! entries: [WalletLogEntry!]!
cursor: String cursor: String
} }
@ -420,25 +194,11 @@ const typeDefs = gql`
type WalletLogEntry { type WalletLogEntry {
id: ID! id: ID!
createdAt: Date! createdAt: Date!
wallet: Wallet wallet: ID!
protocol: WalletProtocol
level: String! level: String!
message: String! message: String!
context: JSONObject context: JSONObject
} }
type VaultEntry {
id: ID!
iv: String!
value: String!
createdAt: Date!
updatedAt: Date!
}
input VaultEntryInput {
iv: String!
value: String!
keyHash: String!
}
` `
export default typeDefs
export default gql`${injectTypeDefs(typeDefs)}`

View File

@ -177,84 +177,31 @@ jason-me,pr,#1857,,easy,,,,100k,rrbtc@vlt.ge,2025-02-08
ed-kung,pr,#1901,#323,good-first-issue,,,,20k,simplestacker@getalby.com,2025-02-14 ed-kung,pr,#1901,#323,good-first-issue,,,,20k,simplestacker@getalby.com,2025-02-14
Scroogey-SN,pr,#1911,#1905,good-first-issue,,,1,18k,Scroogey@coinos.io,2025-03-10 Scroogey-SN,pr,#1911,#1905,good-first-issue,,,1,18k,Scroogey@coinos.io,2025-03-10
Scroogey-SN,pr,#1928,#1924,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10 Scroogey-SN,pr,#1928,#1924,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10
dtonon,issue,#1928,#1924,good-first-issue,,,,2k,tips@dtonon.com,2025-04-16 dtonon,issue,#1928,#1924,good-first-issue,,,,2k,???,???
ed-kung,pr,#1926,#1914,medium-hard,,,,500k,simplestacker@getalby.com,2025-03-10 ed-kung,pr,#1926,#1914,medium-hard,,,,500k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1926,#1914,medium-hard,,,,50k,simplestacker@getalby.com,2025-03-10 ed-kung,issue,#1926,#1914,medium-hard,,,,50k,simplestacker@getalby.com,2025-03-10
ed-kung,pr,#1926,#1927,easy,,,,100k,simplestacker@getalby.com,2025-03-10 ed-kung,pr,#1926,#1927,easy,,,,100k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1926,#1927,easy,,,,10k,simplestacker@getalby.com,2025-03-10 ed-kung,issue,#1926,#1927,easy,,,,10k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1913,#1890,good-first-issue,,,,2k,simplestacker@getalby.com,2025-03-10 ed-kung,issue,#1913,#1890,good-first-issue,,,,2k,simplestacker@getalby.com,2025-03-10
Scroogey-SN,pr,#1930,#1167,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10 Scroogey-SN,pr,#1930,#1167,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10
itsrealfake,issue,#1930,#1167,good-first-issue,,,,2k,smallimagination100035@getalby.com,2025-04-02 itsrealfake,issue,#1930,#1167,good-first-issue,,,,2k,smallimagination100035@getalby.com,???
Scroogey-SN,pr,#1948,#1849,medium,urgent,,,750k,Scroogey@coinos.io,2025-03-10 Scroogey-SN,pr,#1948,#1849,medium,urgent,,,750k,Scroogey@coinos.io,2025-03-10
felipebueno,issue,#1947,#1945,good-first-issue,,,,2k,felipebueno@blink.sv,2025-03-10 felipebueno,issue,#1947,#1945,good-first-issue,,,,2k,felipebueno@blink.sv,2025-03-10
ed-kung,pr,#1952,#1951,easy,,,,100k,simplestacker@getalby.com,2025-03-10 ed-kung,pr,#1952,#1951,easy,,,,100k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1952,#1951,easy,,,,10k,simplestacker@getalby.com,2025-03-10 ed-kung,issue,#1952,#1951,easy,,,,10k,simplestacker@getalby.com,2025-03-10
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,2025-04-02 Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,???
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,me@benthecarman.com,2025-04-16 benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,???,???
ed-kung,pr,#2012,#2004,easy,,,,100k,simplestacker@getalby.com,2025-04-02 ed-kung,pr,#2012,#2004,easy,,,,100k,simplestacker@getalby.com,???
ed-kung,issue,#2012,#2004,easy,,,,10k,simplestacker@getalby.com,2025-04-02 ed-kung,issue,#2012,#2004,easy,,,,10k,simplestacker@getalby.com,???
ed-kung,pr,#1993,#1982,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02 ed-kung,pr,#1993,#1982,good-first-issue,,,,20k,simplestacker@getalby.com,???
rideandslide,issue,#1993,#1982,good-first-issue,,,,2k,koiora@getalby.com,2025-04-02 rideandslide,issue,#1993,#1982,good-first-issue,,,,2k,???,???
ed-kung,pr,#1972,#1254,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02 ed-kung,pr,#1972,#1254,good-first-issue,,,,20k,simplestacker@getalby.com,???
SatsAllDay,issue,#1972,#1254,good-first-issue,,,,2k,weareallsatoshi@getalby.com,2025-04-02 SatsAllDay,issue,#1972,#1254,good-first-issue,,,,2k,weareallsatoshi@getalby.com,???
ed-kung,pr,#1962,#1343,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02 ed-kung,pr,#1962,#1343,good-first-issue,,,,20k,simplestacker@getalby.com,???
ed-kung,pr,#1962,#1217,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02 ed-kung,pr,#1962,#1217,good-first-issue,,,,20k,simplestacker@getalby.com,???
ed-kung,pr,#1962,#866,easy,,,,100k,simplestacker@getalby.com,2025-04-02 ed-kung,pr,#1962,#866,easy,,,,100k,simplestacker@getalby.com,???
felipebueno,issue,#1962,#866,easy,,,,10k,felipebueno@blink.sv,2025-04-02 felipebueno,issue,#1962,#866,easy,,,,10k,felipebueno@blink.sv,???
cointastical,issue,#1962,#1217,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16 cointastical,issue,#1962,#1217,good-first-issue,,,,2k,cointastical@stacker.news,???
Scroogey-SN,pr,#1975,#1964,good-first-issue,,,,20k,Scroogey@coinos.io,2025-04-02 Scroogey-SN,pr,#1975,#1964,good-first-issue,,,,20k,Scroogey@coinos.io,???
rideandslide,issue,#1986,#1985,good-first-issue,,,,2k,koiora@getalby.com,2025-04-02 rideandslide,issue,#1986,#1985,good-first-issue,,,,2k,???,???
kristapsk,issue,#1976,#841,good-first-issue,,,,2k,kristapsk@stacker.news,2025-04-16 kristapsk,issue,#1976,#841,good-first-issue,,,,2k,???,???
ed-kung,pr,#2070,#2061,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-16
ed-kung,issue,#2070,#2061,good-first-issue,,,,2k,simplestacker@getalby.com,2025-04-16
ed-kung,pr,#2070,#2058,easy,,,,100k,simplestacker@getalby.com,2025-04-16
ed-kung,pr,#2070,#2047,medium-hard,,,,500k,simplestacker@getalby.com,2025-04-16
SouthKoreaLN,pr,#2068,#2064,good-first-issue,,,,20k,south_korea_ln@stacker.news,2025-04-16
kepford,issue,#2068,#2064,good-first-issue,,,,2k,penalwink141@minibits.cash,2025-04-16
SouthKoreaLN,pr,#2069,#1990,good-first-issue,,,,20k,south_korea_ln@stacker.news,2025-04-16
cointastical,issue,#2071,#1475,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
brymut,pr,#2082,#2051,easy,low,3,,35k,brymut@stacker.news,2025-04-16
abhiShandy,pr,#2083,#1270,good-first-issue,,,,20k,abhishandy@stacker.news,2025-04-16
cointastical,issue,#2083,#1270,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
brymut,pr,#2093,#1991,easy,,,,100k,brymut@stacker.news,2025-04-16
brymut,pr,#2100,#2090,easy,,3,,70k,brymut@stacker.news,2025-04-24
abhiShandy,pr,#2109,#1221,good-first-issue,,,,20k,abhishandy@stacker.news,2025-04-24
Gudnessuche,issue,#2109,#1221,good-first-issue,,,,2k,everythingsatoshi@getalby.com,2025-04-24
brymut,pr,#2153,#2087,easy,,,,100k,brymut@stacker.news,2025-05-13
brymut,pr,#2152,#2142,good-first-issue,,1,,18k,brymut@stacker.news,2025-05-13
m0wer,pr,#2124,#992,medium,,1,,225k,klk@stacker.news,2025-05-13
ed-kung,issue,#2072,#2043,easy,,,,10k,simplestacker@getalby.com,2025-05-15
ed-kung,helpfulness,#2072,#2043,easy,,,,10k,simplestacker@getalby.com,2025-05-15
SouthKoreaLN,pr,#2072,#2043,easy,,,,100k,south_korea_ln@stacker.news,2025-05-13
m0wer,pr,#2135,#1391,easy,,1,more difficult than planned,150k,klk@stacker.news,2025-05-13
sutt,pr,#2162,#2161,good-first-issue,,,,20k,bounty_hunter@stacker.news,2025-05-15
sutt,issue,#2162,#2161,good-first-issue,,,,2k,bounty_hunter@stacker.news,2025-05-15
brymut,pr,#2171,#2164,easy,,,,100k,brymut@stacker.news,2025-05-21
SouthKoreaLN,issue,#2171,#2164,easy,,,,10k,south_korea_ln@stacker.news,2025-05-21
brymut,pr,#2175,#2173,good-first-issue,,,,20k,brymut@stacker.news,2025-05-21
sutt,pr,#2185,#2183,easy,high,,,200k,bounty_hunter@stacker.news,2025-06-19
sutt,issue,#2185,#2183,easy,high,,,20k,bounty_hunter@stacker.news,2025-06-06
axelvyrn,advisory,#2205,GHSA-x2xp-x867-4jfc,,,,,100k,holonite@speed.app,2025-06-06
brymut,pr,#2184,#2165,easy,,,,100k,brymut@stacker.news,2025-06-06
sutt,pr,#2190,#2187,easy,,,,100k,bounty_hunter@stacker.news,2025-06-06
sutt,issue,#2190,#2187,easy,,,,10k,bounty_hunter@stacker.news,2025-06-06
sutt,pr,#2192,#2188,medium,,,,250k,bounty_hunter@stacker.news,2025-06-19
sutt,issue,#2192,#2188,medium,,,,25k,bounty_hunter@stacker.news,2025-06-19
abhiShandy,pr,#2195,#2181,good-first-issue,,1,,18k,abhishandy@stacker.news,2025-06-13
brymut,pr,#2191,#1409,medium,,2,,200k,brymut@stacker.news,2025-06-13
SatsAllDay,issue,#2191,#1409,medium,,,,20k,weareallsatoshi@getalby.com,2025-06-13
ed-kung,pr,#2217,#2039,medium,,,,250k,simplestacker@getalby.com,2025-06-18
ed-kung,issue,#2217,#2039,medium,,,,25k,simplestacker@getalby.com,2025-06-18
axelvyrn,pr,#2220,#2198,good-first-issue,,5,,10k,holonite@speed.app,2025-06-18
axelvyrn,issue,#2220,#2198,good-first-issue,,,,1k,holonite@speed.app,2025-06-18
brymut,pr,#2221,#2204,good-first-issue,,,,20k,brymut@stacker.news,2025-06-18
brymut,pr,#2235,#2233,good-first-issue,,,,20k,brymut@stacker.news,2025-06-18
brymut,pr,#2250,#2106,good-first-issue,,,,20k,brymut@stacker.news,2025-07-12
SouthKoreaLN,issue,#2267,#2164,easy,,,,10k,south_korea_ln@stacker.news,2025-07-12
pory-gone,pr,#2316,#2277,good-first-issue,,,,20k,pory@porygone.xyz,2025-08-01
brymut,pr,#2326,,good-first-issue,,,,20k,brymut@stacker.news,2025-07-31
brymut,pr,#2332,#2276,easy,,,,100k,brymut@stacker.news,2025-07-31
ed-kung,pr,#2373,#2371,good-first-issue,,,,20k,simplestacker@getalby.com,2025-07-31
ed-kung,issue,#2373,#2371,good-first-issue,,,,2k,simplestacker@getalby.com,2025-07-31
pory-gone,pr,#2381,#2370,good-first-issue,,,,20k,pory@porygone.xyz,???
pory-gone,pr,#2413,#2361,easy,,,,100k,pory@porygone.xyz,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
177 ed-kung pr #1901 #323 good-first-issue 20k simplestacker@getalby.com 2025-02-14
178 Scroogey-SN pr #1911 #1905 good-first-issue 1 18k Scroogey@coinos.io 2025-03-10
179 Scroogey-SN pr #1928 #1924 good-first-issue 20k Scroogey@coinos.io 2025-03-10
180 dtonon issue #1928 #1924 good-first-issue 2k tips@dtonon.com ??? 2025-04-16 ???
181 ed-kung pr #1926 #1914 medium-hard 500k simplestacker@getalby.com 2025-03-10
182 ed-kung issue #1926 #1914 medium-hard 50k simplestacker@getalby.com 2025-03-10
183 ed-kung pr #1926 #1927 easy 100k simplestacker@getalby.com 2025-03-10
184 ed-kung issue #1926 #1927 easy 10k simplestacker@getalby.com 2025-03-10
185 ed-kung issue #1913 #1890 good-first-issue 2k simplestacker@getalby.com 2025-03-10
186 Scroogey-SN pr #1930 #1167 good-first-issue 20k Scroogey@coinos.io 2025-03-10
187 itsrealfake issue #1930 #1167 good-first-issue 2k smallimagination100035@getalby.com 2025-04-02 ???
188 Scroogey-SN pr #1948 #1849 medium urgent 750k Scroogey@coinos.io 2025-03-10
189 felipebueno issue #1947 #1945 good-first-issue 2k felipebueno@blink.sv 2025-03-10
190 ed-kung pr #1952 #1951 easy 100k simplestacker@getalby.com 2025-03-10
191 ed-kung issue #1952 #1951 easy 10k simplestacker@getalby.com 2025-03-10
192 Scroogey-SN pr #1973 #1959 good-first-issue 20k Scroogey@coinos.io 2025-04-02 ???
193 benthecarman issue #1953 #1950 good-first-issue 2k me@benthecarman.com ??? 2025-04-16 ???
194 ed-kung pr #2012 #2004 easy 100k simplestacker@getalby.com 2025-04-02 ???
195 ed-kung issue #2012 #2004 easy 10k simplestacker@getalby.com 2025-04-02 ???
196 ed-kung pr #1993 #1982 good-first-issue 20k simplestacker@getalby.com 2025-04-02 ???
197 rideandslide issue #1993 #1982 good-first-issue 2k koiora@getalby.com ??? 2025-04-02 ???
198 ed-kung pr #1972 #1254 good-first-issue 20k simplestacker@getalby.com 2025-04-02 ???
199 SatsAllDay issue #1972 #1254 good-first-issue 2k weareallsatoshi@getalby.com 2025-04-02 ???
200 ed-kung pr #1962 #1343 good-first-issue 20k simplestacker@getalby.com 2025-04-02 ???
201 ed-kung pr #1962 #1217 good-first-issue 20k simplestacker@getalby.com 2025-04-02 ???
202 ed-kung pr #1962 #866 easy 100k simplestacker@getalby.com 2025-04-02 ???
203 felipebueno issue #1962 #866 easy 10k felipebueno@blink.sv 2025-04-02 ???
204 cointastical issue #1962 #1217 good-first-issue 2k Cointastical@getAlby.com cointastical@stacker.news 2025-04-16 ???
205 Scroogey-SN pr #1975 #1964 good-first-issue 20k Scroogey@coinos.io 2025-04-02 ???
206 rideandslide issue #1986 #1985 good-first-issue 2k koiora@getalby.com ??? 2025-04-02 ???
207 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 ???

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"express": "^4.20.0", "express": "^4.18.2",
"puppeteer": "^20.8.2" "puppeteer": "^20.8.2"
}, },
"type": "module" "type": "module"

View File

@ -46,7 +46,7 @@ export default function AccordianItem ({ header, body, className, headerColor =
<Accordion defaultActiveKey={activeKey} activeKey={activeKey} onSelect={handleOnSelect}> <Accordion defaultActiveKey={activeKey} activeKey={activeKey} onSelect={handleOnSelect}>
<ContextAwareToggle show={show} eventKey={KEY_ID} headerColor={headerColor}><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle> <ContextAwareToggle show={show} eventKey={KEY_ID} headerColor={headerColor}><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle>
<Accordion.Collapse eventKey={KEY_ID} className={classNames('mt-2', className)}> <Accordion.Collapse eventKey={KEY_ID} className={classNames('mt-2', className)}>
<div key={activeKey}>{body}</div> <div>{body}</div>
</Accordion.Collapse> </Accordion.Collapse>
</Accordion> </Accordion>
) )

View File

@ -1,44 +1,165 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { USER_ID } from '@/lib/constants' import * as cookie from 'cookie'
import { useMe } from '@/components/me'
import { USER_ID, SSR } from '@/lib/constants'
import { USER } from '@/fragments/users' import { USER } from '@/fragments/users'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import { UserListRow } from '@/components/user-list' import { UserListRow } from '@/components/user-list'
import useCookie from '@/components/use-cookie'
import Link from 'next/link' import Link from 'next/link'
import AddIcon from '@/svgs/add-fill.svg' import AddIcon from '@/svgs/add-fill.svg'
import { MultiAuthErrorBanner } from '@/components/banners'
import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_LIST, MULTI_AUTH_POINTER } from '@/lib/auth' import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_LIST, MULTI_AUTH_POINTER } from '@/lib/auth'
const AccountContext = createContext()
const CHECK_ERRORS_INTERVAL_MS = 5_000
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8') const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
export const nextAccount = async () => { export const AccountProvider = ({ children }) => {
const { status } = await fetch('/api/next-account', { credentials: 'include' }) const [accounts, setAccounts] = useState([])
// if status is 302, this means the server was able to switch us to the next available account const [meAnon, setMeAnon] = useState(true)
return status === 302 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 default function SwitchAccountList () { export default function SwitchAccountList () {
const { accounts, multiAuthErrors } = useAccounts()
const router = useRouter() const router = useRouter()
const accounts = useAccounts()
const [pointerCookie] = useCookie(MULTI_AUTH_POINTER)
const hasError = multiAuthErrors.length > 0
if (hasError) {
return (
<>
<div className='my-2'>
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
<MultiAuthErrorBanner errors={multiAuthErrors} />
</div>
</div>
</>
)
}
// can't show hat since the streak is not included in the JWT payload
return ( return (
<> <>
<div className='my-2'> <div className='my-2'>
<div className='d-flex flex-column flex-wrap mt-2 mb-3'> <div className='d-flex flex-column flex-wrap mt-2 mb-3'>
<h4 className='text-muted'>Accounts</h4> <h4 className='text-muted'>Accounts</h4>
<AccountListRow <AccountListRow account={{ id: USER_ID.anon, name: 'anon' }} showHat={false} />
account={{ id: USER_ID.anon, name: 'anon' }}
selected={pointerCookie === MULTI_AUTH_ANON}
showHat={false}
/>
{ {
accounts.map((account) => accounts.map((account) => <AccountListRow key={account.id} account={account} showHat={false} />)
<AccountListRow
key={account.id}
account={account}
selected={Number(pointerCookie) === account.id}
showHat={false}
/>)
} }
</div> </div>
<Link <Link
@ -54,45 +175,3 @@ export default function SwitchAccountList () {
</> </>
) )
} }
const AccountListRow = ({ account, selected, ...props }) => {
const router = useRouter()
const [, setPointerCookie] = useCookie(MULTI_AUTH_POINTER)
// fetch updated names and photo ids since they might have changed since we were issued the JWTs
const { data, error } = useQuery(USER, { variables: { id: account.id } })
if (error) console.error(`query for user ${account.id} failed:`, error)
const name = data?.user?.name || account.name
const photoId = data?.user?.photoId || account.photoId
const onClick = async (e) => {
// prevent navigation
e.preventDefault()
// update pointer cookie
const options = cookieOptions({ httpOnly: false })
const anon = account.id === USER_ID.anon
setPointerCookie(anon ? MULTI_AUTH_ANON : account.id, options)
// reload whatever page we're on to avoid any bugs due to missing authorization etc.
router.reload()
}
return (
<div className='d-flex flex-row'>
<UserListRow
user={{ ...account, photoId, name }}
className='d-flex align-items-center me-2'
selected={selected}
{...props}
onNymClick={onClick}
/>
</div>
)
}
export const useAccounts = () => {
const [listCookie] = useCookie(MULTI_AUTH_LIST)
return listCookie ? JSON.parse(b64Decode(listCookie)) : []
}

View File

@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import { Input, InputUserSuggest, VariableInput, Checkbox } from './form' import { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import { BOOST_MIN, BOOST_MAX, BOOST_MULT, MAX_FORWARDS, SSR } from '@/lib/constants' import { BOOST_MIN, BOOST_MULT, MAX_FORWARDS, SSR } from '@/lib/constants'
import { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr' import { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
import Info from './info' import Info from './info'
import { abbrNum, numWithUnits } from '@/lib/format' import { abbrNum, numWithUnits } from '@/lib/format'
@ -37,7 +37,6 @@ export function BoostHelp () {
<li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li> <li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li>
<li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li> <li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li> <li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
<li>The maximum boost is {numWithUnits(BOOST_MAX, { abbreviate: false })}</li>
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker (very rare) <li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker (very rare)
<ul> <ul>
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li> <li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li>
@ -198,7 +197,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
for (let i = 0; i < MAX_FORWARDS; i++) { for (let i = 0; i < MAX_FORWARDS; i++) {
['nym', 'pct'].forEach(key => { ['nym', 'pct'].forEach(key => {
const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`) const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`)
if (value !== undefined && value !== null) { if (value) {
formik?.setFieldValue(`forward[${i}].${key}`, value) formik?.setFieldValue(`forward[${i}].${key}`, value)
} }
}) })
@ -269,7 +268,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
emptyItem={EMPTY_FORWARD} emptyItem={EMPTY_FORWARD}
hint={<span className='text-muted'>Forward sats to up to 5 other stackers. Any remaining sats go to you.</span>} hint={<span className='text-muted'>Forward sats to up to 5 other stackers. Any remaining sats go to you.</span>}
> >
{({ index, AppendColumn }) => { {({ index, placeholder }) => {
return ( return (
<div key={index} className='d-flex flex-row'> <div key={index} className='d-flex flex-row'>
<InputUserSuggest <InputUserSuggest
@ -286,7 +285,6 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
max={100} max={100}
append={<InputGroup.Text className='text-monospace'>%</InputGroup.Text>} append={<InputGroup.Text className='text-monospace'>%</InputGroup.Text>}
groupClassName={`${styles.percent} mb-0`} groupClassName={`${styles.percent} mb-0`}
AppendColumn={AppendColumn}
/> />
</div> </div>
) )

View File

@ -1,275 +0,0 @@
import { useCallback, createContext, useContext, useState, useEffect } from 'react'
import Particles from 'react-particles'
import { loadFireworksPreset } from 'tsparticles-preset-fireworks'
import styles from './fireworks.module.css'
import {
rgbToHsl,
setRangeValue,
stringToRgb
} from 'tsparticles-engine'
import useDarkMode from '@/components/dark-mode'
export const FireworksContext = createContext({
strike: () => {}
})
export const FireworksConsumer = FireworksContext.Consumer
export function useFireworks () {
const { strike } = useContext(FireworksContext)
return strike
}
export function FireworksProvider ({ children }) {
const [cont, setCont] = useState()
const [context, setContext] = useState({ strike: () => {} })
const [darkMode] = useDarkMode()
useEffect(() => {
setContext({
strike: () => {
const should = window.localStorage.getItem('lnAnimate') || 'yes'
if (should !== 'yes') return false
cont?.addEmitter(
{
direction: 'top',
life: {
count: 1,
duration: 0.1,
delay: 0.1
},
rate: {
delay: 0,
quantity: 1
},
size: {
width: 10,
height: 0
},
position: {
y: 100,
x: 50
}
})
return true
}
})
}, [cont])
const particlesLoaded = useCallback((container) => {
setCont(container)
}, [])
const particlesInit = useCallback(async engine => {
// you can initiate the tsParticles instance (engine) here, adding custom shapes or presets
// this loads the tsparticles package bundle, it's the easiest method for getting everything ready
// starting from v2 you can add only the features you need reducing the bundle size
await loadFireworksPreset(engine)
}, [])
return (
<FireworksContext.Provider value={context}>
<Particles
className={styles.fireworks}
init={particlesInit}
loaded={particlesLoaded}
options={darkMode ? darkOptions : lightOptions}
/>
{children}
</FireworksContext.Provider>
)
}
const fixRange = (value, min, max) => {
const diffSMax = value.max > max ? value.max - max : 0
let res = setRangeValue(value)
if (diffSMax) {
res = setRangeValue(value.min - diffSMax, max)
}
const diffSMin = value.min < min ? value.min : 0
if (diffSMin) {
res = setRangeValue(0, value.max + diffSMin)
}
return res
}
const fireworksOptions = ['#ff595e', '#ffca3a', '#8ac926', '#1982c4', '#6a4c93']
.map((color) => {
const rgb = stringToRgb(color)
if (!rgb) {
return undefined
}
const hsl = rgbToHsl(rgb)
const sRange = fixRange({ min: hsl.s - 30, max: hsl.s + 30 }, 0, 100)
const lRange = fixRange({ min: hsl.l - 30, max: hsl.l + 30 }, 0, 100)
return {
color: {
value: {
h: hsl.h,
s: sRange,
l: lRange
}
},
stroke: {
width: 0
},
number: {
value: 0
},
opacity: {
value: {
min: 0.1,
max: 1
},
animation: {
enable: true,
speed: 0.7,
sync: false,
startValue: 'max',
destroy: 'min'
}
},
shape: {
type: 'circle'
},
size: {
value: { min: 1, max: 2 },
animation: {
enable: true,
speed: 5,
count: 1,
sync: false,
startValue: 'min',
destroy: 'none'
}
},
life: {
count: 1,
duration: {
value: {
min: 1,
max: 2
}
}
},
move: {
decay: { min: 0.075, max: 0.1 },
enable: true,
gravity: {
enable: true,
inverse: false,
acceleration: 5
},
speed: { min: 5, max: 15 },
direction: 'none',
outMode: {
top: 'destroy',
default: 'bounce'
}
}
}
})
.filter((t) => t !== undefined)
const particlesOptions = (theme) => ({
number: {
value: 0
},
destroy: {
mode: 'split',
bounds: {
top: { min: 5, max: 40 }
},
split: {
sizeOffset: false,
count: 1,
factor: {
value: 0.333333
},
rate: {
value: { min: 75, max: 150 }
},
particles: fireworksOptions
}
},
life: {
count: 1
},
shape: {
type: 'line'
},
size: {
value: {
min: 0.1,
max: 50
},
animation: {
enable: true,
sync: true,
speed: 90,
startValue: 'max',
destroy: 'min'
}
},
rotate: {
path: true
},
stroke: {
color: {
value: theme === 'dark' ? '#fff' : '#aaa'
},
width: 1
},
move: {
enable: true,
gravity: {
acceleration: 15,
enable: true,
inverse: true,
maxSpeed: 100
},
speed: {
min: 10,
max: 20
},
outModes: {
default: 'split',
top: 'none'
},
trail: {
fillColor: theme === 'dark' ? '#000' : '#f5f5f7',
enable: true,
length: 10
}
}
})
const darkOptions = {
fullScreen: { enable: true, zIndex: -1 },
detectRetina: true,
background: {
color: '#000',
opacity: 0
},
fpsLimit: 120,
emitters: [],
particles: particlesOptions('dark')
}
const lightOptions = {
fullScreen: { enable: true, zIndex: -1 },
detectRetina: true,
background: {
color: '#fff',
opacity: 0
},
fpsLimit: 120,
emitters: [],
particles: particlesOptions('light')
}

View File

@ -1,8 +0,0 @@
.fireworks {
z-index: 0;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}

View File

@ -1,72 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { useMe } from '@/components/me'
import { randInRange } from '@/lib/rand'
import { LightningProvider, useLightning } from './lightning'
// import { FireworksProvider, useFireworks } from './fireworks'
// import { SnowProvider, useSnow } from './snow'
const [SelectedAnimationProvider, useSelectedAnimation] = [
LightningProvider, useLightning
// FireworksProvider, useFireworks
// SnowProvider, useSnow // TODO: the snow animation doesn't seem to work anymore
]
export function AnimationProvider ({ children }) {
return (
<SelectedAnimationProvider>
<AnimationHooks>
{children}
</AnimationHooks>
</SelectedAnimationProvider>
)
}
export function useAnimation () {
const animate = useSelectedAnimation()
return useCallback(() => {
const should = window.localStorage.getItem('lnAnimate') || 'yes'
if (should !== 'yes') return false
animate()
return true
}, [animate])
}
export function useAnimationEnabled () {
const [enabled, setEnabled] = useState(undefined)
useEffect(() => {
const enabled = window.localStorage.getItem('lnAnimate') || 'yes'
setEnabled(enabled === 'yes')
}, [])
const toggleEnabled = useCallback(() => {
setEnabled(enabled => {
const newEnabled = !enabled
window.localStorage.setItem('lnAnimate', newEnabled ? 'yes' : 'no')
return newEnabled
})
}, [])
return [enabled, toggleEnabled]
}
function AnimationHooks ({ children }) {
const { me } = useMe()
const animate = useAnimation()
useEffect(() => {
if (me || window.localStorage.getItem('striked') || window.localStorage.getItem('lnAnimated')) return
const timeout = setTimeout(() => {
const animated = animate()
if (animated) {
window.localStorage.setItem('lnAnimated', 'yep')
}
}, randInRange(3000, 10000))
return () => clearTimeout(timeout)
}, [me?.id, animate])
return children
}

View File

@ -0,0 +1,76 @@
import { InputGroup } from 'react-bootstrap'
import { Input } from './form'
import { useMe } from './me'
import { useEffect, useState } from 'react'
import { isNumber } from '@/lib/format'
import Link from 'next/link'
function autoWithdrawThreshold ({ me }) {
return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
}
export function autowithdrawInitial ({ me }) {
return {
autoWithdrawThreshold: autoWithdrawThreshold({ me }),
autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1,
autoWithdrawMaxFeeTotal: isNumber(me?.privates?.autoWithdrawMaxFeeTotal) ? me?.privates?.autoWithdrawMaxFeeTotal : 1
}
}
export function AutowithdrawSettings () {
const { me } = useMe()
const threshold = autoWithdrawThreshold({ me })
const [sendThreshold, setSendThreshold] = useState(Math.max(Math.floor(threshold / 10), 1))
useEffect(() => {
setSendThreshold(Math.max(Math.floor(threshold / 10), 1))
}, [autoWithdrawThreshold])
return (
<>
<div className='my-4 border border-3 rounded'>
<div className='p-3'>
<h3 className='text-center text-muted'>desired balance</h3>
<h6 className='text-center pb-3'>applies globally to all autowithdraw methods</h6>
<Input
label='desired balance'
name='autoWithdrawThreshold'
onChange={(formik, e) => {
const value = e.target.value
setSendThreshold(Math.max(Math.floor(value / 10), 1))
}}
hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
required
/>
<h3 className='text-center text-muted pt-3'>network fees</h3>
<h6 className='text-center pb-3'>
we'll use whichever setting is higher during{' '}
<Link
target='_blank'
href='https://docs.lightning.engineering/the-lightning-network/pathfinding'
rel='noreferrer'
>pathfinding
</Link>
</h6>
<Input
label='max fee rate'
name='autoWithdrawMaxFeePercent'
hint='max fee as percent of withdrawal amount'
append={<InputGroup.Text>%</InputGroup.Text>}
required
/>
<Input
label='max fee total'
name='autoWithdrawMaxFeeTotal'
hint='max fee for any withdrawal amount'
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
required
/>
</div>
</div>
</>
)
}

View File

@ -6,18 +6,12 @@ import EditImage from '@/svgs/image-edit-fill.svg'
import Moon from '@/svgs/moon-fill.svg' import Moon from '@/svgs/moon-fill.svg'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { FileUpload } from './file-upload' import { FileUpload } from './file-upload'
import { gql, useMutation } from '@apollo/client'
export default function Avatar ({ onSuccess }) { export default function Avatar ({ onSuccess }) {
const [cropPhoto] = useMutation(gql`
mutation cropPhoto($photoId: ID!, $cropData: CropData) {
cropPhoto(photoId: $photoId, cropData: $cropData)
}
`)
const [uploading, setUploading] = useState() const [uploading, setUploading] = useState()
const showModal = useShowModal() const showModal = useShowModal()
const Body = ({ onClose, file, onSave }) => { const Body = ({ onClose, file, upload }) => {
const [scale, setScale] = useState(1) const [scale, setScale] = useState(1)
const ref = useRef() const ref = useRef()
@ -40,21 +34,13 @@ export default function Avatar ({ onSuccess }) {
/> />
</BootstrapForm.Group> </BootstrapForm.Group>
<Button <Button
onClick={async () => { onClick={() => {
const rect = ref.current.getCroppingRect() ref.current.getImageScaledToCanvas().toBlob(blob => {
const img = new window.Image() if (blob) {
img.onload = async () => { upload(blob)
const cropData = { onClose()
...rect,
originalWidth: img.width,
originalHeight: img.height,
scale
} }
// upload original to S3 along with crop data }, 'image/jpeg')
await onSave(cropData)
}
img.src = URL.createObjectURL(file)
onClose()
}} }}
>save >save
</Button> </Button>
@ -62,45 +48,6 @@ export default function Avatar ({ onSuccess }) {
) )
} }
const startCrop = async (file, upload) => {
return new Promise((resolve, reject) =>
showModal(onClose => (
<Body
onClose={() => {
onClose()
resolve()
}}
file={file}
onSave={async (cropData) => {
setUploading(true)
try {
// upload original to S3
const photoId = await upload(file)
// crop it
const { data } = await cropPhoto({ variables: { photoId, cropData } })
const res = await fetch(data.cropPhoto)
const blob = await res.blob()
// create a file from the blob
const croppedImage = new File([blob], 'avatar.jpg', { type: 'image/jpeg' })
// upload the imgproxy cropped image
const croppedPhotoId = await upload(croppedImage)
onSuccess?.(croppedPhotoId)
setUploading(false)
} catch (e) {
console.error(e)
setUploading(false)
reject(e)
}
}}
/>
))
)
}
return ( return (
<FileUpload <FileUpload
allow='image/*' allow='image/*'
@ -109,7 +56,26 @@ export default function Avatar ({ onSuccess }) {
console.log(e) console.log(e)
setUploading(false) setUploading(false)
}} }}
onSelect={startCrop} onSelect={(file, upload) => {
return new Promise((resolve, reject) =>
showModal(onClose => (
<Body
onClose={() => {
onClose()
resolve()
}}
file={file}
upload={async (blob) => {
await upload(blob)
resolve(blob)
}}
/>
)))
}}
onSuccess={({ id }) => {
onSuccess?.(id)
setUploading(false)
}}
onUpload={() => { onUpload={() => {
setUploading(true) setUploading(true)
}} }}

View File

@ -1,14 +1,29 @@
import { Fragment } from 'react'
import OverlayTrigger from 'react-bootstrap/OverlayTrigger' import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
import Tooltip from 'react-bootstrap/Tooltip' import Tooltip from 'react-bootstrap/Tooltip'
import CowboyHatIcon from '@/svgs/cowboy.svg' import CowboyHatIcon from '@/svgs/cowboy.svg'
import AnonIcon from '@/svgs/spy-fill.svg' import AnonIcon from '@/svgs/spy-fill.svg'
import GunIcon from '@/svgs/revolver.svg'
import HorseIcon from '@/svgs/horse.svg'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import { USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
import GunIcon from '@/svgs/revolver.svg'
import HorseIcon from '@/svgs/horse.svg'
import classNames from 'classnames' import classNames from 'classnames'
const BADGES = [
{
icon: CowboyHatIcon,
streakName: 'streak'
},
{
icon: HorseIcon,
streakName: 'horseStreak'
},
{
icon: GunIcon,
streakName: 'gunStreak',
sizeDelta: 2
}
]
export default function Badges ({ user, badge, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) { export default function Badges ({ user, badge, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) {
if (!user || Number(user.id) === USER_ID.ad) return null if (!user || Number(user.id) === USER_ID.ad) return null
if (Number(user.id) === USER_ID.anon) { if (Number(user.id) === USER_ID.anon) {
@ -19,43 +34,14 @@ export default function Badges ({ user, badge, className = 'ms-1', badgeClassNam
) )
} }
const badges = []
const streak = user.optional.streak
if (streak !== null) {
badges.push({
icon: CowboyHatIcon,
overlayText: streak
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
: 'new'
})
}
if (user.optional.hasRecvWallet) {
badges.push({
icon: HorseIcon,
overlayText: 'can receive sats'
})
}
if (user.optional.hasSendWallet) {
badges.push({
icon: GunIcon,
sizeDelta: 2,
overlayText: 'can send sats'
})
}
if (badges.length === 0) return null
return ( return (
<span className={className}> <span className={className}>
{badges.map(({ icon, overlayText, sizeDelta }, i) => ( {BADGES.map(({ icon, streakName, sizeDelta }, i) => (
<SNBadge <SNBadge
key={i} key={streakName}
user={user} user={user}
badge={badge} badge={badge}
overlayText={overlayText} streakName={streakName}
badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)} badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
IconForBadge={icon} IconForBadge={icon}
height={height} height={height}
@ -67,19 +53,20 @@ export default function Badges ({ user, badge, className = 'ms-1', badgeClassNam
) )
} }
function SNBadge ({ user, badge, overlayText, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) { function SNBadge ({ user, badge, streakName, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
let Wrapper = Fragment const streak = user.optional[streakName]
if (streak === null) {
if (overlayText) { return null
Wrapper = ({ children }) => (
<BadgeTooltip overlayText={overlayText}>{children}</BadgeTooltip>
)
} }
return ( return (
<Wrapper> <BadgeTooltip
overlayText={streak
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
: 'new'}
>
<span><IconForBadge className={badgeClassName} height={height + sizeDelta} width={width + sizeDelta} /></span> <span><IconForBadge className={badgeClassName} height={height + sizeDelta} width={width + sizeDelta} /></span>
</Wrapper> </BadgeTooltip>
) )
} }

View File

@ -5,6 +5,8 @@ import { useMe } from '@/components/me'
import { useMutation } from '@apollo/client' import { useMutation } from '@apollo/client'
import { WELCOME_BANNER_MUTATION } from '@/fragments/users' import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
import { useToast } from '@/components/toast' import { useToast } from '@/components/toast'
import Link from 'next/link'
import AccordianItem from '@/components/accordian-item'
export function WelcomeBanner ({ Banner }) { export function WelcomeBanner ({ Banner }) {
const { me } = useMe() const { me } = useMe()
@ -99,6 +101,22 @@ export function MadnessBanner ({ handleClose }) {
) )
} }
export function WalletSecurityBanner ({ isActive }) {
return (
<Alert className={styles.banner} key='info' variant='warning'>
<Alert.Heading>
Gunslingin' Safety Tips
</Alert.Heading>
<p className='mb-3 line-height-md'>
Listen up, pardner! Put a limit on yer spendin' wallet or hook up a wallet that's only for Stacker News. It'll keep them varmints from cleanin' out yer whole goldmine if they rustle up yer wallet.
</p>
<p className='line-height-md'>
Your spending wallet's credentials are never sent to our servers in plain text. To sync across devices, <Alert.Link as={Link} href='/settings/passphrase'>enable device sync in your settings</Alert.Link>.
</p>
</Alert>
)
}
export function AuthBanner () { export function AuthBanner () {
return ( return (
<Alert className={`${styles.banner} mt-0`} key='info' variant='danger'> <Alert className={`${styles.banner} mt-0`} key='info' variant='danger'>
@ -106,3 +124,24 @@ export function AuthBanner () {
</Alert> </Alert>
) )
} }
export function MultiAuthErrorBanner ({ errors }) {
return (
<Alert className={styles.banner} key='info' variant='danger'>
<div className='fw-bold mb-3'>Account switching is currently unavailable</div>
<AccordianItem
className='my-3'
header='We have detected the following issues:'
headerColor='var(--bs-danger-text-emphasis)'
body={
<ul>
{errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
}
/>
<div className='mt-3'>To resolve these issues, please sign out and sign in again.</div>
</Alert>
)
}

View File

@ -53,19 +53,12 @@ function useArrowKeys ({ moveLeft, moveRight }) {
}, [onKeyDown]) }, [onKeyDown])
} }
function Carousel ({ close, mediaArr, src, setOptions }) { export default function Carousel ({ close, mediaArr, src, originalSrc, setOptions }) {
const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src)) const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src))
const [currentSrc, canGoLeft, canGoRight] = useMemo(() => { const [currentSrc, canGoLeft, canGoRight] = useMemo(() => {
return [mediaArr[index][0], index > 0, index < mediaArr.length - 1] return [mediaArr[index][0], index > 0, index < mediaArr.length - 1]
}, [mediaArr, index]) }, [mediaArr, index])
useEffect(() => {
if (index === -1) return
setOptions({
overflow: <CarouselOverflow {...mediaArr[index][1]} />
})
}, [index, mediaArr, setOptions])
const moveLeft = useCallback(() => { const moveLeft = useCallback(() => {
setIndex(i => Math.max(0, i - 1)) setIndex(i => Math.max(0, i - 1))
}, [setIndex]) }, [setIndex])
@ -121,15 +114,15 @@ export function CarouselProvider ({ children }) {
fullScreen: true, fullScreen: true,
overflow: <CarouselOverflow {...media.current.get(src)} /> overflow: <CarouselOverflow {...media.current.get(src)} />
}) })
}, [showModal]) }, [showModal, media.current])
const addMedia = useCallback(({ src, originalSrc, rel }) => { const addMedia = useCallback(({ src, originalSrc, rel }) => {
media.current.set(src, { src, originalSrc, rel }) media.current.set(src, { src, originalSrc, rel })
}, []) }, [media.current])
const removeMedia = useCallback((src) => { const removeMedia = useCallback((src) => {
media.current.delete(src) media.current.delete(src)
}, []) }, [media.current])
const value = useMemo(() => ({ showCarousel, addMedia, removeMedia }), [showCarousel, addMedia, removeMedia]) const value = useMemo(() => ({ showCarousel, addMedia, removeMedia }), [showCarousel, addMedia, removeMedia])
return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider> return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider>

View File

@ -96,7 +96,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
} }
export default function Comment ({ export default function Comment ({
item, children, replyOpen, includeParent, topLevel, rootLastCommentAt, item, children, replyOpen, includeParent, topLevel,
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
}) { }) {
const [edit, setEdit] = useState() const [edit, setEdit] = useState()
@ -114,17 +114,6 @@ export default function Comment ({
const { cache } = useApolloClient() const { cache } = useApolloClient()
const unsetOutline = () => {
if (!ref.current) return
const hasOutline = ref.current.classList.contains('outline-new-comment') || ref.current.classList.contains('outline-new-injected-comment')
const hasOutlineUnset = ref.current.classList.contains('outline-new-comment-unset')
// don't try to unset the outline if the comment is not outlined or we already unset the outline
if (hasOutline && !hasOutlineUnset) {
ref.current.classList.add('outline-new-comment-unset')
}
}
useEffect(() => { useEffect(() => {
const comment = cache.readFragment({ const comment = cache.readFragment({
id: `Item:${router.query.commentId}`, id: `Item:${router.query.commentId}`,
@ -151,29 +140,12 @@ export default function Comment ({
}, [item.id, cache, router.query.commentId]) }, [item.id, cache, router.query.commentId])
useEffect(() => { useEffect(() => {
if (me?.id === item.user?.id) return if (router.query.commentsViewedAt &&
me?.id !== item.user?.id &&
const itemCreatedAt = new Date(item.createdAt).getTime() new Date(item.createdAt).getTime() > router.query.commentsViewedAt) {
// it's a new comment if it was created after the last comment was viewed
// or, in the case of live comments, after the last comment was created
const isNewComment = (router.query.commentsViewedAt && itemCreatedAt > router.query.commentsViewedAt) ||
(rootLastCommentAt && itemCreatedAt > new Date(rootLastCommentAt).getTime())
if (!isNewComment) return
if (item.injected) {
// newly injected comments (item.injected) have to use a different class to outline every new comment
ref.current.classList.add('outline-new-injected-comment')
// wait for the injection animation to end before removing its class
ref.current.addEventListener('animationend', () => {
ref.current.classList.remove(styles.injectedComment)
}, { once: true })
// animate the live comment injection
ref.current.classList.add(styles.injectedComment)
} else {
ref.current.classList.add('outline-new-comment') ref.current.classList.add('outline-new-comment')
} }
}, [item.id, rootLastCommentAt]) }, [item.id])
const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0) const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0)
// Don't show OP badge when anon user comments on anon user posts // Don't show OP badge when anon user comments on anon user posts
@ -187,19 +159,17 @@ export default function Comment ({
return ( return (
<div <div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`} ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
onMouseEnter={unsetOutline} onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
onTouchStart={unsetOutline} onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
> >
<div className={`${itemStyles.item} ${styles.item}`}> <div className={`${itemStyles.item} ${styles.item}`}>
{item.outlawed && !me?.privates?.wildWestMode {item.outlawed && !me?.privates?.wildWestMode
? <Skull className={styles.dontLike} width={24} height={24} /> ? <Skull className={styles.dontLike} width={24} height={24} />
: pin : item.mine
? <Pin width={22} height={22} className={styles.pin} /> ? <Boost item={item} className={styles.upvote} />
: item.mine : item.meDontLikeSats > item.meSats
? <Boost item={item} className={styles.upvote} /> ? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: item.meDontLikeSats > item.meSats : pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} collapsed={collapse === 'yep'} />}
? <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={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
{item.user?.meMute && !includeParent && collapse === 'yep' {item.user?.meMute && !includeParent && collapse === 'yep'
@ -212,7 +182,6 @@ export default function Comment ({
>reply from someone you muted >reply from someone you muted
</span>) </span>)
: <ItemInfo : <ItemInfo
full={topLevel}
item={item} item={item}
commentsText='replies' commentsText='replies'
commentTextSingular='reply' commentTextSingular='reply'
@ -280,7 +249,7 @@ export default function Comment ({
</div> </div>
{collapse !== 'yep' && ( {collapse !== 'yep' && (
bottomedOut bottomedOut
? <div className={styles.children}><div className={classNames(styles.comment, 'mt-3 pb-2')}><ViewMoreReplies item={item} threadContext /></div></div> ? <div className={styles.children}><div className={classNames(styles.comment, 'mt-3')}><ReplyOnAnotherPage item={item} /></div></div>
: ( : (
<div className={styles.children}> <div className={styles.children}>
{item.outlawed && !me?.privates?.wildWestMode {item.outlawed && !me?.privates?.wildWestMode
@ -295,13 +264,9 @@ export default function Comment ({
? ( ? (
<> <>
{item.comments.comments.map((item) => ( {item.comments.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} rootLastCommentAt={rootLastCommentAt} /> <Comment depth={depth + 1} key={item.id} item={item} />
))} ))}
{item.comments.comments.length < item.nDirectComments && ( {item.comments.comments.length < item.nDirectComments && <ViewAllReplies id={item.id} nhas={item.ncomments} />}
<div className={`d-block ${styles.comment} pb-2 ps-3`}>
<ViewMoreReplies item={item} />
</div>
)}
</> </>
) )
: null} : null}
@ -314,24 +279,29 @@ export default function Comment ({
) )
} }
export function ViewMoreReplies ({ item, threadContext = false }) { export function ViewAllReplies ({ id, nshown, nhas }) {
const root = useRoot() const text = `view all ${nhas} replies`
const id = threadContext ? commentSubTreeRootId(item, root) : item.id
// if threadContext is true, we travel to some comments before the current comment, focusing on the comment itself
// otherwise, we directly navigate to the comment
const href = `/items/${id}` + (threadContext ? `?commentId=${item.id}` : '')
const text = threadContext && item.ncomments === 0
? 'reply on another page'
: `view all ${item.ncomments} replies`
return ( return (
<Link <div className={`d-block fw-bold ${styles.comment} pb-2 ps-3`}>
href={href} <Link href={`/items/${id}`} as={`/items/${id}`} className='text-muted'>
as={`/items/${id}`} {text}
className='fw-bold d-flex align-items-center gap-2 text-muted' </Link>
> </div>
)
}
function ReplyOnAnotherPage ({ item }) {
const root = useRoot()
const rootId = commentSubTreeRootId(item, root)
let text = 'reply on another page'
if (item.ncomments > 0) {
text = `view all ${item.ncomments} replies`
}
return (
<Link href={`/items/${rootId}?commentId=${item.id}`} as={`/items/${rootId}`} className='d-block pb-2 fw-bold text-muted'>
{text} {text}
</Link> </Link>
) )

View File

@ -136,35 +136,3 @@
.comment:has(.comment) + .comment{ .comment:has(.comment) + .comment{
padding-top: .5rem; padding-top: .5rem;
} }
.newCommentDot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--bs-primary);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
background-color: #80d3ff;
opacity: 0.7;
}
50% {
background-color: #007cbe;
opacity: 1;
}
100% {
background-color: #80d3ff;
opacity: 0.7;
}
}
.injectedComment {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}

View File

@ -8,7 +8,6 @@ import { defaultCommentSort } from '@/lib/item'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import MoreFooter from './more-footer' import MoreFooter from './more-footer'
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
import useLiveComments from './use-live-comments'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter() const router = useRouter()
@ -65,13 +64,10 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
export default function Comments ({ export default function Comments ({
parentId, pinned, bio, parentCreatedAt, parentId, pinned, bio, parentCreatedAt,
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, lastCommentAt, item, ...props commentSats, comments, commentsCursor, fetchMoreComments, ncomments, ...props
}) { }) {
const router = useRouter() const router = useRouter()
// fetch new comments that arrived after the lastCommentAt, and update the item.comments field in cache
useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort)
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
return ( return (
@ -94,11 +90,11 @@ export default function Comments ({
: null} : null}
{pins.map(item => ( {pins.map(item => (
<Fragment key={item.id}> <Fragment key={item.id}>
<Comment depth={1} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin /> <Comment depth={1} item={item} {...props} pin />
</Fragment> </Fragment>
))} ))}
{comments.filter(({ position }) => !position).map(item => ( {comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} /> <Comment depth={1} key={item.id} item={item} {...props} />
))} ))}
{ncomments > FULL_COMMENTS_THRESHOLD && {ncomments > FULL_COMMENTS_THRESHOLD &&
<MoreFooter <MoreFooter

View File

@ -125,21 +125,19 @@ const Embed = memo(function Embed ({ src, provider, id, meta, className, topLeve
// This Twitter embed could use similar logic to the video embeds below // This Twitter embed could use similar logic to the video embeds below
if (provider === 'twitter') { if (provider === 'twitter') {
return ( return (
<> <div className={classNames(styles.twitterContainer, !show && styles.twitterContained, className)}>
<div className={classNames(styles.twitterContainer, !show && styles.twitterContained, className)}> <TwitterTweetEmbed
<TwitterTweetEmbed tweetId={id}
tweetId={id} options={{ theme: darkMode ? 'dark' : 'light', width: topLevel ? '550px' : '350px' }}
options={{ theme: darkMode ? 'dark' : 'light', width: topLevel ? '550px' : '350px' }} key={darkMode ? '1' : '2'}
key={darkMode ? '1' : '2'} placeholder={<TweetSkeleton className={className} />}
placeholder={<TweetSkeleton className={className} />} onLoad={() => setOverflowing(true)}
onLoad={() => setOverflowing(true)} />
/> {overflowing && !show &&
{overflowing && !show && <Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}> show full tweet
show full tweet </Button>}
</Button>} </div>
</div>
</>
) )
} }

View File

@ -3,6 +3,7 @@ import { StaticLayout } from './layout'
import styles from '@/styles/error.module.css' import styles from '@/styles/error.module.css'
import Image from 'react-bootstrap/Image' import Image from 'react-bootstrap/Image'
import copy from 'clipboard-copy' import copy from 'clipboard-copy'
import { LoggerContext } from './logger'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import { useToast } from './toast' import { useToast } from './toast'
import { decodeMinifiedStackTrace } from '@/lib/stacktrace' import { decodeMinifiedStackTrace } from '@/lib/stacktrace'
@ -35,6 +36,8 @@ class ErrorBoundary extends Component {
// You can use your own error logging service here // You can use your own error logging service here
console.log({ error, errorInfo }) console.log({ error, errorInfo })
this.setState({ errorInfo }) this.setState({ errorInfo })
const logger = this.context
logger?.error(this.getErrorDetails())
} }
render () { render () {
@ -44,7 +47,7 @@ class ErrorBoundary extends Component {
const errorDetails = this.getErrorDetails() const errorDetails = this.getErrorDetails()
return ( return (
<StaticLayout footer={false}> <StaticLayout footer={false}>
<Image width='500' height='375' className='rounded-1 shadow-sm' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.webp`} fluid /> <Image width='500' height='375' className='rounded-1 shadow-sm' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.gif`} fluid />
<h1 className={styles.status} style={{ fontSize: '48px' }}>something went wrong</h1> <h1 className={styles.status} style={{ fontSize: '48px' }}>something went wrong</h1>
{this.state.error && <CopyErrorButton errorDetails={errorDetails} />} {this.state.error && <CopyErrorButton errorDetails={errorDetails} />}
</StaticLayout> </StaticLayout>
@ -56,6 +59,8 @@ class ErrorBoundary extends Component {
} }
} }
ErrorBoundary.contextType = LoggerContext
export default ErrorBoundary export default ErrorBoundary
// This button is a functional component so we can use `useToast` hook, which // This button is a functional component so we can use `useToast` hook, which

View File

@ -12,10 +12,10 @@ import No from '@/svgs/no.svg'
import Bolt from '@/svgs/bolt.svg' import Bolt from '@/svgs/bolt.svg'
import Amboss from '@/svgs/amboss.svg' import Amboss from '@/svgs/amboss.svg'
import Mempool from '@/svgs/bimi.svg' import Mempool from '@/svgs/bimi.svg'
import { useEffect, useState } from 'react'
import Rewards from './footer-rewards' import Rewards from './footer-rewards'
import useDarkMode from './dark-mode' import useDarkMode from './dark-mode'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
import { useAnimationEnabled } from '@/components/animation'
const RssPopover = ( const RssPopover = (
<Popover> <Popover>
@ -53,43 +53,33 @@ const RssPopover = (
const SocialsPopover = ( const SocialsPopover = (
<Popover> <Popover>
<Popover.Body style={{ fontWeight: 500, fontSize: '.9rem' }}> <Popover.Body style={{ fontWeight: 500, fontSize: '.9rem' }}>
<div className='d-flex justify-content-center'> <a
<a href='https://njump.me/npub1jfujw6llhq7wuvu5detycdsq5v5yqf56sgrdq8wlgrryx2a2p09svwm0gx' className='nav-link p-0 d-inline-flex'
href='https://njump.me/npub1jfujw6llhq7wuvu5detycdsq5v5yqf56sgrdq8wlgrryx2a2p09svwm0gx' className='nav-link p-0 d-inline-flex' target='_blank' rel='noreferrer'
target='_blank' rel='noreferrer' >
> nostr
nostr </a>
</a> <span className='mx-2 text-muted'> \ </span>
<span className='mx-2 text-muted'> \ </span> <a
<a href='https://twitter.com/stacker_news' className='nav-link p-0 d-inline-flex'
href='https://twitter.com/stacker_news' className='nav-link p-0 d-inline-flex' target='_blank' rel='noreferrer'
target='_blank' rel='noreferrer' >
> twitter
twitter </a>
</a> <span className='mx-2 text-muted'> \ </span>
<span className='mx-2 text-muted'> \ </span> <a
<a href='https://www.youtube.com/@stackernews' className='nav-link p-0 d-inline-flex'
href='https://www.youtube.com/@stackernews' className='nav-link p-0 d-inline-flex' target='_blank' rel='noreferrer'
target='_blank' rel='noreferrer' >
> youtube
youtube </a>
</a> <span className='mx-2 text-muted'> \ </span>
</div> <a
<div className='d-flex justify-content-center'> href='https://www.fountain.fm/show/Mg1AWuvkeZSFhsJZ3BW2' className='nav-link p-0 d-inline-flex'
<a target='_blank' rel='noreferrer'
href='https://www.fountain.fm/show/Mg1AWuvkeZSFhsJZ3BW2' className='nav-link p-0 d-inline-flex' >
target='_blank' rel='noreferrer' pod
> </a>
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.Body>
</Popover> </Popover>
) )
@ -145,10 +135,24 @@ const LegalPopover = (
export default function Footer ({ links = true }) { export default function Footer ({ links = true }) {
const [darkMode, darkModeToggle] = useDarkMode() const [darkMode, darkModeToggle] = useDarkMode()
const [animationEnabled, toggleAnimation] = useAnimationEnabled() const [lightning, setLightning] = useState(undefined)
useEffect(() => {
setLightning(window.localStorage.getItem('lnAnimate') || 'yes')
}, [])
const toggleLightning = () => {
if (lightning === 'yes') {
window.localStorage.setItem('lnAnimate', 'no')
setLightning('no')
} else {
window.localStorage.setItem('lnAnimate', 'yes')
setLightning('yes')
}
}
const DarkModeIcon = darkMode ? Sun : Moon const DarkModeIcon = darkMode ? Sun : Moon
const LnIcon = animationEnabled ? No : Bolt const LnIcon = lightning === 'yes' ? No : Bolt
const version = process.env.NEXT_PUBLIC_COMMIT_HASH const version = process.env.NEXT_PUBLIC_COMMIT_HASH
@ -161,8 +165,8 @@ export default function Footer ({ links = true }) {
<ActionTooltip notForm overlayText={`${darkMode ? 'disable' : 'enable'} dark mode`}> <ActionTooltip notForm overlayText={`${darkMode ? 'disable' : 'enable'} dark mode`}>
<DarkModeIcon onClick={darkModeToggle} width={20} height={20} className='fill-grey theme' suppressHydrationWarning /> <DarkModeIcon onClick={darkModeToggle} width={20} height={20} className='fill-grey theme' suppressHydrationWarning />
</ActionTooltip> </ActionTooltip>
<ActionTooltip notForm overlayText={`${animationEnabled ? 'disable' : 'enable'} lightning animations`}> <ActionTooltip notForm overlayText={`${lightning === 'yes' ? 'disable' : 'enable'} lightning animations`}>
<LnIcon onClick={toggleAnimation} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning /> <LnIcon onClick={toggleLightning} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
</ActionTooltip> </ActionTooltip>
</div> </div>
<div className='mb-0' style={{ fontWeight: 500 }}> <div className='mb-0' style={{ fontWeight: 500 }}>

View File

@ -16,7 +16,6 @@ import AddIcon from '@/svgs/add-fill.svg'
import CloseIcon from '@/svgs/close-line.svg' import CloseIcon from '@/svgs/close-line.svg'
import { gql, useLazyQuery } from '@apollo/client' import { gql, useLazyQuery } from '@apollo/client'
import { USER_SUGGESTIONS } from '@/fragments/users' import { USER_SUGGESTIONS } from '@/fragments/users'
import { SUB_SUGGESTIONS } from '@/fragments/subs'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { useToast } from './toast' import { useToast } from './toast'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
@ -34,9 +33,12 @@ import Info from './info'
import { useMe } from './me' import { useMe } from './me'
import classNames from 'classnames' import classNames from 'classnames'
import Clipboard from '@/svgs/clipboard-line.svg' import Clipboard from '@/svgs/clipboard-line.svg'
import QrIcon from '@/svgs/qr-code-line.svg'
import QrScanIcon from '@/svgs/qr-scan-line.svg' import QrScanIcon from '@/svgs/qr-scan-line.svg'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { QRCodeSVG } from 'qrcode.react'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { qrImageSettings } from './qr'
import { useIsClient } from './use-client' import { useIsClient } from './use-client'
import PageLoading from './page-loading' import PageLoading from './page-loading'
@ -75,7 +77,7 @@ export function SubmitButton ({
) )
} }
export function CopyButton ({ value, icon, ...props }) { function CopyButton ({ value, icon, ...props }) {
const toaster = useToast() const toaster = useToast()
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@ -137,174 +139,6 @@ function setNativeValue (textarea, value) {
textarea.dispatchEvent(new Event('input', { bubbles: true, value })) textarea.dispatchEvent(new Event('input', { bubbles: true, value }))
} }
function useEntityAutocomplete ({
prefix,
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent
}) {
const [entityData, setEntityData] = useState()
const handleSelect = useCallback((name) => {
if (entityData?.start === undefined || entityData?.end === undefined) return
const { start, end } = entityData
setEntityData(undefined)
const first = `${meta?.value.substring(0, start)}${prefix}${name}`
const second = meta?.value.substring(end)
const updatedValue = `${first}${second}`
helpers.setValue(updatedValue)
setSelectionRange({ start: first.length, end: first.length })
innerRef.current.focus()
}, [entityData, meta?.value, helpers, prefix, setSelectionRange, innerRef])
const handleTextChange = useCallback((e) => {
const { value, selectionStart } = e.target
if (!value || selectionStart === undefined) {
setEntityData(undefined)
return false
}
let priorSpace = -1
for (let i = selectionStart - 1; i >= 0; i--) {
if (/[^\w@~]/.test(value[i])) {
priorSpace = i
break
}
}
let nextSpace = value.length
for (let i = selectionStart; i <= value.length; i++) {
if (/[^\w]/.test(value[i])) {
nextSpace = i
break
}
}
const currentSegment = value.substring(priorSpace + 1, nextSpace)
const regexPattern = new RegExp(`^\\${prefix}\\w*$`)
if (regexPattern.test(currentSegment)) {
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
setEntityData({
query: currentSegment,
start: priorSpace + 1,
end: nextSpace,
style: {
position: 'absolute',
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
left: `${left}px`
}
})
return true
}
setEntityData(undefined)
return false
}, [prefix])
// Return a function that takes a render prop instead of directly returning the component
return {
entityData,
handleSelect,
handleTextChange,
renderSuggest: (renderProps) => {
if (!entityData) return null
return (
<SuggestComponent
query={entityData?.query}
onSelect={handleSelect}
dropdownStyle={entityData?.style}
>
{renderProps}
</SuggestComponent>
)
}
}
}
export function useDualAutocomplete ({ meta, helpers, innerRef, setSelectionRange }) {
const userAutocomplete = useEntityAutocomplete({
prefix: '@',
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: UserSuggest
})
const territoryAutocomplete = useEntityAutocomplete({
prefix: '~',
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: TerritorySuggest
})
const handleTextChange = useCallback((e) => {
// Try to match user mentions first, then territories
if (!userAutocomplete.handleTextChange(e)) {
territoryAutocomplete.handleTextChange(e)
}
}, [userAutocomplete, territoryAutocomplete])
const handleKeyDown = useCallback((e, userOnKeyDown, territoryOnKeyDown) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (!metaOrCtrl) {
if (userAutocomplete.entityData) {
return userOnKeyDown(e)
} else if (territoryAutocomplete.entityData) {
return territoryOnKeyDown(e)
}
}
return false // Didn't handle the event
}, [userAutocomplete.entityData, territoryAutocomplete.entityData])
const handleBlur = useCallback((resetUserSuggestions, resetTerritorySuggestions) => {
setTimeout(resetUserSuggestions, 500)
setTimeout(resetTerritorySuggestions, 500)
}, [])
return {
userAutocomplete,
territoryAutocomplete,
handleTextChange,
handleKeyDown,
handleBlur
}
}
export function DualAutocompleteWrapper ({
userAutocomplete,
territoryAutocomplete,
children
}) {
return (
<UserSuggest
query={userAutocomplete.entityData?.query}
onSelect={userAutocomplete.handleSelect}
dropdownStyle={userAutocomplete.entityData?.style}
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions: resetUserSuggestions }) => (
<TerritorySuggest
query={territoryAutocomplete.entityData?.query}
onSelect={territoryAutocomplete.handleSelect}
dropdownStyle={territoryAutocomplete.entityData?.style}
>{({ onKeyDown: territorySuggestOnKeyDown, resetSuggestions: resetTerritorySuggestions }) =>
children({
userSuggestOnKeyDown,
territorySuggestOnKeyDown,
resetUserSuggestions,
resetTerritorySuggestions
})}
</TerritorySuggest>
)}
</UserSuggest>
)
}
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) { export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
const [tab, setTab] = useState('write') const [tab, setTab] = useState('write')
const [, meta, helpers] = useField(props) const [, meta, helpers] = useField(props)
@ -317,8 +151,10 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
const [updateUploadFees] = useLazyQuery(gql` const [updateUploadFees] = useLazyQuery(gql`
query uploadFees($s3Keys: [Int]!) { query uploadFees($s3Keys: [Int]!) {
uploadFees(s3Keys: $s3Keys) { uploadFees(s3Keys: $s3Keys) {
totalFees
nUnpaid nUnpaid
uploadFees uploadFees
bytes24h
} }
}`, { }`, {
fetchPolicy: 'no-cache', fetchPolicy: 'no-cache',
@ -327,15 +163,13 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
console.error(err) console.error(err)
}, },
onCompleted: ({ uploadFees }) => { onCompleted: ({ uploadFees }) => {
const { uploadFees: feePerUpload, nUnpaid } = uploadFees
const totalFees = feePerUpload * nUnpaid
merge({ merge({
uploadFees: { uploadFees: {
term: `+ ${numWithUnits(feePerUpload, { abbreviate: false })} x ${nUnpaid}`, term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`,
label: 'upload fee', label: 'upload fee',
op: '+', op: '+',
modifier: cost => cost + totalFees, modifier: cost => cost + uploadFees.totalFees,
omit: !totalFees omit: !uploadFees.totalFees
} }
}) })
} }
@ -364,12 +198,18 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
} }
}, [innerRef, selectionRange.start, selectionRange.end]) }, [innerRef, selectionRange.start, selectionRange.end])
const { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({ const [mention, setMention] = useState()
meta, const insertMention = useCallback((name) => {
helpers, if (mention?.start === undefined || mention?.end === undefined) return
innerRef, const { start, end } = mention
setSelectionRange setMention(undefined)
}) const first = `${meta?.value.substring(0, start)}@${name}`
const second = meta?.value.substring(end)
const updatedValue = `${first}${second}`
helpers.setValue(updatedValue)
setSelectionRange({ start: first.length, end: first.length })
innerRef.current.focus()
}, [mention, meta?.value, helpers?.setValue])
const uploadFeesUpdate = useDebounceCallback( const uploadFeesUpdate = useDebounceCallback(
(text) => { (text) => {
@ -379,9 +219,86 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
const onChangeInner = useCallback((formik, e) => { const onChangeInner = useCallback((formik, e) => {
if (onChange) onChange(formik, e) if (onChange) onChange(formik, e)
uploadFeesUpdate(e.target.value) // check for mention editing
handleTextChange(e) const { value, selectionStart } = e.target
}, [onChange, uploadFeesUpdate, handleTextChange]) uploadFeesUpdate(value)
if (!value || selectionStart === undefined) {
setMention(undefined)
return
}
let priorSpace = -1
for (let i = selectionStart - 1; i >= 0; i--) {
if (/[^\w@]/.test(value[i])) {
priorSpace = i
break
}
}
let nextSpace = value.length
for (let i = selectionStart; i <= value.length; i++) {
if (/[^\w]/.test(value[i])) {
nextSpace = i
break
}
}
const currentSegment = value.substring(priorSpace + 1, nextSpace)
// set the query to the current character segment and note where it appears
if (/^@\w*$/.test(currentSegment)) {
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
setMention({
query: currentSegment,
start: priorSpace + 1,
end: nextSpace,
style: {
position: 'absolute',
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
left: `${left}px`
}
})
} else {
setMention(undefined)
}
}, [onChange, setMention, uploadFeesUpdate])
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
return (e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (metaOrCtrl) {
if (e.key === 'k') {
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
e.preventDefault()
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'b') {
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
e.preventDefault()
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'i') {
// some browsers might use CTRL+I to do something else so prevent that behavior too
e.preventDefault()
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'u') {
// some browsers might use CTRL+U to do something else so prevent that behavior too
e.preventDefault()
imageUploadRef.current?.click()
}
if (e.key === 'Tab' && e.altKey) {
e.preventDefault()
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
}
if (!metaOrCtrl) {
userSuggestOnKeyDown(e)
}
if (onKeyDown) onKeyDown(e)
}
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown])
const onPaste = useCallback((event) => { const onPaste = useCallback((event) => {
const items = event.clipboardData.items const items = event.clipboardData.items
@ -425,44 +342,6 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
setDragStyle(null) setDragStyle(null)
}, [setDragStyle]) }, [setDragStyle])
const onKeyDownInner = useCallback((userSuggestOnKeyDown, territorySuggestOnKeyDown) => {
return (e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
// Handle markdown shortcuts first
if (metaOrCtrl) {
if (e.key === 'k') {
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
e.preventDefault()
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'b') {
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
e.preventDefault()
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'i') {
// some browsers might use CTRL+I to do something else so prevent that behavior too
e.preventDefault()
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'u') {
// some browsers might use CTRL+U to do something else so prevent that behavior too
e.preventDefault()
imageUploadRef.current?.click()
}
if (e.key === 'Tab' && e.altKey) {
e.preventDefault()
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
} else {
handleKeyDown(e, userSuggestOnKeyDown, territorySuggestOnKeyDown)
}
if (onKeyDown) onKeyDown(e)
}
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown, handleKeyDown, imageUploadRef])
return ( return (
<FormGroup label={label} className={groupClassName}> <FormGroup label={label} className={groupClassName}>
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}> <div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
@ -529,25 +408,24 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
</span> </span>
</Nav> </Nav>
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}> <div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
<DualAutocompleteWrapper <UserSuggest
userAutocomplete={userAutocomplete} query={mention?.query}
territoryAutocomplete={territoryAutocomplete} onSelect={insertMention}
> dropdownStyle={mention?.style}
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => ( >{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions }) => (
<InputInner <InputInner
innerRef={innerRef} innerRef={innerRef}
{...props} {...props}
onChange={onChangeInner} onChange={onChangeInner}
onKeyDown={onKeyDownInner(userSuggestOnKeyDown, territorySuggestOnKeyDown)} onKeyDown={onKeyDownInner(userSuggestOnKeyDown)}
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)} onBlur={() => setTimeout(resetSuggestions, 500)}
onDragEnter={onDragEnter} onDragEnter={onDragEnter}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
onDrop={onDrop} onDrop={onDrop}
onPaste={onPaste} onPaste={onPaste}
className={dragStyle === 'over' ? styles.dragOver : ''} className={dragStyle === 'over' ? styles.dragOver : ''}
/> />)}
)} </UserSuggest>
</DualAutocompleteWrapper>
</div> </div>
{tab !== 'write' && {tab !== 'write' &&
<div className='form-group'> <div className='form-group'>
@ -609,7 +487,7 @@ function FormGroup ({ className, label, children }) {
function InputInner ({ function InputInner ({
prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue, prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue,
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, hideError, innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, hideError,
AppendColumn, ...props ...props
}) { }) {
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props) const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
const formik = noForm ? null : useFormikContext() const formik = noForm ? null : useFormikContext()
@ -687,43 +565,38 @@ function InputInner ({
return ( return (
<> <>
<Row> <InputGroup hasValidation className={inputGroupClassName}>
<Col> {prepend}
<InputGroup hasValidation className={inputGroupClassName}> <BootstrapForm.Control
{prepend} ref={innerRef}
<BootstrapForm.Control {...field}
ref={innerRef} {...props}
{...field} onKeyDown={onKeyDownInner}
{...props} onChange={onChangeInner}
onKeyDown={onKeyDownInner} onBlur={onBlurInner}
onChange={onChangeInner} isInvalid={!hideError && invalid} // if hideError is true, handle error showing separately
onBlur={onBlurInner} isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
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
{(isClient && clear && field.value && !props.readOnly) && variant={null}
<Button onClick={(e) => {
variant={null} helpers.setValue('')
onClick={(e) => { if (storageKey) {
helpers.setValue('') window.localStorage.removeItem(storageKey)
if (storageKey) { }
window.localStorage.removeItem(storageKey) if (onChange) {
} onChange(formik, { target: { value: '' } })
if (onChange) { }
onChange(formik, { target: { value: '' } }) }}
} className={`${styles.clearButton} ${styles.appendButton} ${invalid ? styles.isInvalid : ''}`}
}} ><CloseIcon className='fill-grey' height={20} width={20} />
className={`${styles.clearButton} ${styles.appendButton} ${invalid ? styles.isInvalid : ''}`} </Button>}
><CloseIcon className='fill-grey' height={20} width={20} /> {append}
</Button>} <BootstrapForm.Control.Feedback type='invalid'>
{append} {meta.touched && meta.error}
<BootstrapForm.Control.Feedback type='invalid'> </BootstrapForm.Control.Feedback>
{meta.touched && meta.error} </InputGroup>
</BootstrapForm.Control.Feedback>
</InputGroup>
</Col>
{AppendColumn && <AppendColumn className={meta.touched && meta.error ? 'invisible' : ''} />}
</Row>
{hint && ( {hint && (
<BootstrapForm.Text> <BootstrapForm.Text>
{hint} {hint}
@ -744,34 +617,34 @@ function InputInner ({
} }
const INITIAL_SUGGESTIONS = { array: [], index: 0 } const INITIAL_SUGGESTIONS = { array: [], index: 0 }
export function UserSuggest ({
export function BaseSuggest ({ query, onSelect, dropdownStyle, children,
query, onSelect, dropdownStyle, transformUser = user => user, selectWithTab = true, filterUsers = () => true
transformItem = item => item, selectWithTab = true, filterItems = () => true,
getSuggestionsQuery, queryName, itemsField,
children
}) { }) {
const [getSuggestions] = useLazyQuery(getSuggestionsQuery, { const [getSuggestions] = useLazyQuery(USER_SUGGESTIONS, {
onCompleted: data => { onCompleted: data => {
query !== undefined && setSuggestions({ query !== undefined && setSuggestions({
array: data[itemsField] array: data.userSuggestions
.filter((...args) => filterItems(query, ...args)) .filter((...args) => filterUsers(query, ...args))
.map(transformItem), .map(transformUser),
index: 0 index: 0
}) })
} }
}) })
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS) const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), []) const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), [])
useEffect(() => { useEffect(() => {
if (query !== undefined) { if (query !== undefined) {
// remove the leading character and any trailing spaces // remove both the leading @ and any @domain after nym
const q = query?.replace(/^[@ ~]+|[ ]+$/g, '').replace(/@[^\s]*$/, '').replace(/~[^\s]*$/, '') const q = query?.replace(/^[@ ]+|[ ]+$/g, '').replace(/@[^\s]*$/, '')
getSuggestions({ variables: { q, limit: 5 } }) getSuggestions({ variables: { q, limit: 5 } })
} else { } else {
resetSuggestions() resetSuggestions()
} }
}, [query, resetSuggestions, getSuggestions]) }, [query, resetSuggestions, getSuggestions])
const onKeyDown = useCallback(e => { const onKeyDown = useCallback(e => {
switch (e.code) { switch (e.code) {
case 'ArrowUp': case 'ArrowUp':
@ -816,6 +689,7 @@ export function BaseSuggest ({
break break
} }
}, [onSelect, resetSuggestions, suggestions]) }, [onSelect, resetSuggestions, suggestions])
return ( return (
<> <>
{children?.({ onKeyDown, resetSuggestions })} {children?.({ onKeyDown, resetSuggestions })}
@ -838,17 +712,17 @@ export function BaseSuggest ({
) )
} }
function BaseInputSuggest ({ export function InputUserSuggest ({
label, groupClassName, transformItem, filterItems, label, groupClassName, transformUser, filterUsers,
selectWithTab, onChange, transformQuery, SuggestComponent, prefixRegex, ...props selectWithTab, onChange, transformQuery, ...props
}) { }) {
const [ovalue, setOValue] = useState() const [ovalue, setOValue] = useState()
const [query, setQuery] = useState() const [query, setQuery] = useState()
return ( return (
<FormGroup label={label} className={groupClassName}> <FormGroup label={label} className={groupClassName}>
<SuggestComponent <UserSuggest
transformItem={transformItem} transformUser={transformUser}
filterItems={filterItems} filterUsers={filterUsers}
selectWithTab={selectWithTab} selectWithTab={selectWithTab}
onSelect={(v) => { onSelect={(v) => {
// HACK ... ovalue does not trigger onChange // HACK ... ovalue does not trigger onChange
@ -863,85 +737,19 @@ function BaseInputSuggest ({
autoComplete='off' autoComplete='off'
onChange={(formik, e) => { onChange={(formik, e) => {
onChange && onChange(formik, e) onChange && onChange(formik, e)
if (e.target.value === ovalue) {
// we don't need to set the ovalue or query if the value is the same
return
}
setOValue(e.target.value) setOValue(e.target.value)
setQuery(e.target.value.replace(prefixRegex, '')) setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, ''))
}} }}
overrideValue={ovalue} overrideValue={ovalue}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onBlur={() => setTimeout(resetSuggestions, 500)} onBlur={() => setTimeout(resetSuggestions, 500)}
/> />
)} )}
</SuggestComponent> </UserSuggest>
</FormGroup> </FormGroup>
) )
} }
export function InputUserSuggest ({
transformUser, filterUsers, ...props
}) {
return (
<BaseInputSuggest
transformItem={transformUser}
filterItems={filterUsers}
SuggestComponent={UserSuggest}
prefixRegex={/^[@ ]+|[ ]+$/g}
{...props}
/>
)
}
export function InputTerritorySuggest ({
transformSub, filterSubs, ...props
}) {
return (
<BaseInputSuggest
transformItem={transformSub}
filterItems={filterSubs}
SuggestComponent={TerritorySuggest}
prefixRegex={/^[~ ]+|[ ]+$/g}
{...props}
/>
)
}
function UserSuggest ({
transformUser = user => user, filterUsers = () => true,
children, ...props
}) {
return (
<BaseSuggest
transformItem={transformUser}
filterItems={filterUsers}
getSuggestionsQuery={USER_SUGGESTIONS}
itemsField='userSuggestions'
{...props}
>
{children}
</BaseSuggest>
)
}
function TerritorySuggest ({
transformSub = sub => sub, filterSubs = () => true,
children, ...props
}) {
return (
<BaseSuggest
transformItem={transformSub}
filterItems={filterSubs}
getSuggestionsQuery={SUB_SUGGESTIONS}
itemsField='subSuggestions'
{...props}
>
{children}
</BaseSuggest>
)
}
export function Input ({ label, groupClassName, under, ...props }) { export function Input ({ label, groupClassName, under, ...props }) {
return ( return (
<FormGroup label={label} className={groupClassName}> <FormGroup label={label} className={groupClassName}>
@ -957,38 +765,31 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
<FieldArray name={name} hasValidation> <FieldArray name={name} hasValidation>
{({ form, ...fieldArrayHelpers }) => { {({ form, ...fieldArrayHelpers }) => {
const options = form.values[name] const options = form.values[name]
return ( return (
<> <>
{options?.map((_, i) => { {options?.map((_, i) => (
const AppendColumn = ({ className }) => ( <div key={i}>
<Col className={`d-flex ps-0 ${className}`} xs='auto'> <Row className='mb-2'>
{options.length - 1 === i && options.length !== max <Col>
// onMouseDown is used to prevent the blur event on text inputs from overriding the click event {children
? <AddIcon className='fill-grey align-self-center justify-self-center pointer' onMouseDown={() => fieldArrayHelpers.push(emptyItem)} /> ? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined })
// filler div for col alignment across rows : <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} />}
: <div style={{ width: '24px', height: '24px' }} />} </Col>
</Col> <Col className='d-flex ps-0' xs='auto'>
) {options.length - 1 === i && options.length !== max
return ( ? <AddIcon className='fill-grey align-self-center justify-self-center pointer' onClick={() => fieldArrayHelpers.push(emptyItem)} />
<div key={i}> // filler div for col alignment across rows
<Row className='mb-2'> : <div style={{ width: '24px', height: '24px' }} />}
<Col> </Col>
{children {options.length - 1 === i &&
? 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} />} {hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
</Col> {form.touched[name] && typeof form.errors[name] === 'string' &&
<div className='invalid-feedback d-block'>{form.errors[name]}</div>}
{options.length - 1 === i && </>}
<> </Row>
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>} </div>
{form.touched[name] && typeof form.errors[name] === 'string' && ))}
<div className='invalid-feedback d-block'>{form.errors[name]}</div>}
</>}
</Row>
</div>
)
})}
</> </>
) )
}} }}
@ -1077,14 +878,15 @@ export function Form ({
}) })
}, [storageKeyPrefix]) }, [storageKeyPrefix])
const onSubmitInner = useCallback(async (values, ...args) => { const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => {
const variables = { amount, ...values }
if (requireSession && !me) { if (requireSession && !me) {
throw new SessionRequiredError() throw new SessionRequiredError()
} }
try { try {
if (onSubmit) { if (onSubmit) {
await onSubmit(values, ...args) await onSubmit(variables, ...args)
} }
} catch (err) { } catch (err) {
console.log(err.message, err) console.log(err.message, err)
@ -1342,6 +1144,33 @@ function PasswordHider ({ onClick, showPass }) {
) )
} }
function QrPassword ({ value }) {
const showModal = useShowModal()
const toaster = useToast()
const showQr = useCallback(() => {
showModal(close => (
<div>
<p className='line-height-md text-muted'>Import this passphrase into another device by navigating to device sync settings and scanning this QR code</p>
<div className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }}>
<QRCodeSVG className='h-auto mw-100' value={value} size={300} imageSettings={qrImageSettings} />
</div>
</div>
))
}, [toaster, value, showModal])
return (
<>
<InputGroup.Text
style={{ cursor: 'pointer' }}
onClick={showQr}
>
<QrIcon height={16} width={16} />
</InputGroup.Text>
</>
)
}
function PasswordScanner ({ onScan, text }) { function PasswordScanner ({ onScan, text }) {
const showModal = useShowModal() const showModal = useShowModal()
const toaster = useToast() const toaster = useToast()
@ -1362,10 +1191,8 @@ function PasswordScanner ({ onScan, text }) {
<Scanner <Scanner
formats={['qr_code']} formats={['qr_code']}
onScan={([{ rawValue: result }]) => { onScan={([{ rawValue: result }]) => {
if (result) { onScan(result)
onScan(result) onClose()
onClose()
}
}} }}
styles={{ styles={{
video: { video: {
@ -1380,7 +1207,6 @@ function PasswordScanner ({ onScan, text }) {
} }
onClose() onClose()
}} }}
components={{ audio: false }}
/> />
)} )}
</div> </div>
@ -1407,12 +1233,12 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, value: ini
{copy && ( {copy && (
<CopyButton icon value={field?.value} /> <CopyButton icon value={field?.value} />
)} )}
{qr && ( {qr && (readOnly
<PasswordScanner ? <QrPassword value={field?.value} />
text="Where'd you learn to square dance?" : <PasswordScanner
onScan={v => helpers.setValue(v)} text="Where'd you learn to square dance?"
/> onScan={v => helpers.setValue(v)}
)} />)}
{append} {append}
</> </>
) )

View File

@ -13,12 +13,12 @@ export default function CCInfo (props) {
<ul> <ul>
<li>if the zap is small and you don't have a direct channel to SN, the routing fee may exceed SN's 3% max fee</li> <li>if the zap is small and you don't have a direct channel to SN, the routing fee may exceed SN's 3% max fee</li>
<li>check your <Link href='/wallets/logs'>wallet logs</Link> for clues</li> <li>check your <Link href='/wallets/logs'>wallet logs</Link> for clues</li>
<li>if you have questions about the errors in your wallet logs, mention the error in the <Link href='/api/daily'>saloon</Link></li> <li>if you have questions about the errors in your wallet logs, mention the error in the <Link href='/daily'>saloon</Link></li>
</ul> </ul>
</li> </li>
<li>some zaps might be smaller than your configured receiving dust limit <li>some zaps might be smaller than your configured receiving dust limit
<ul> <ul>
<li>you can configure your dust limit in your <Link href='/wallets/settings'>wallet settings</Link></li> <li>you can configure your dust limit in your <Link href='/settings'>settings</Link></li>
</ul> </ul>
</li> </li>
</ul> </ul>

View File

@ -6,9 +6,9 @@ import { CompactLongCountdown } from './countdown'
import PayerData from './payer-data' import PayerData from './payer-data'
import Bolt11Info from './bolt11-info' import Bolt11Info from './bolt11-info'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import { INVOICE } from '@/fragments/invoice' import { INVOICE } from '@/fragments/wallet'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants' import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/client/errors' import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/errors'
import ItemJob from './item-job' import ItemJob from './item-job'
import Item from './item' import Item from './item'
import { CommentFlat } from './comment' import { CommentFlat } from './comment'

View File

@ -6,22 +6,19 @@ import { useMe } from './me'
import UpBolt from '@/svgs/bolt.svg' import UpBolt from '@/svgs/bolt.svg'
import { amountSchema, boostSchema } from '@/lib/validate' import { amountSchema, boostSchema } from '@/lib/validate'
import { useToast } from './toast' import { useToast } from './toast'
import { useLightning } from './lightning'
import { nextTip, defaultTipIncludingRandom } from './upvote' import { nextTip, defaultTipIncludingRandom } from './upvote'
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants' import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { usePaidMutation } from './use-paid-mutation' import { usePaidMutation } from './use-paid-mutation'
import { ACT_MUTATION } from '@/fragments/paidAction' import { ACT_MUTATION } from '@/fragments/paidAction'
import { meAnonSats } from '@/lib/apollo' import { meAnonSats } from '@/lib/apollo'
import { BoostItemInput } from './adv-post-form' import { BoostItemInput } from './adv-post-form'
import { useHasSendWallet } from '@/wallets/client/hooks' import { useSendWallets } from '@/wallets/index'
import { useAnimation } from '@/components/animation'
const defaultTips = [100, 1000, 10_000, 100_000] const defaultTips = [100, 1000, 10_000, 100_000]
const Tips = ({ setOValue }) => { const Tips = ({ setOValue }) => {
const customTips = getCustomTips() const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
const defaultNoCustom = defaultTips.filter(d => !customTips.includes(d))
const tips = [...customTips, ...defaultNoCustom].slice(0, 7).sort((a, b) => a - b)
return tips.map((num, i) => return tips.map((num, i) =>
<Button <Button
size='sm' size='sm'
@ -40,7 +37,11 @@ const Tips = ({ setOValue }) => {
const getCustomTips = () => JSON.parse(window.localStorage.getItem('custom-tips')) || [] const getCustomTips = () => JSON.parse(window.localStorage.getItem('custom-tips')) || []
const addCustomTip = (amount) => { const addCustomTip = (amount) => {
const customTips = Array.from(new Set([amount, ...getCustomTips()])).slice(0, 7) if (defaultTips.includes(amount)) return
let customTips = Array.from(new Set([amount, ...getCustomTips()]))
if (customTips.length > 3) {
customTips = customTips.slice(0, 3)
}
window.localStorage.setItem('custom-tips', JSON.stringify(customTips)) window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
} }
@ -88,7 +89,7 @@ function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'B
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) { export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
const inputRef = useRef(null) const inputRef = useRef(null)
const { me } = useMe() const { me } = useMe()
const hasSendWallet = useHasSendWallet() const wallets = useSendWallets()
const [oValue, setOValue] = useState() const [oValue, setOValue] = useState()
useEffect(() => { useEffect(() => {
@ -96,7 +97,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
}, [onClose, item.id]) }, [onClose, item.id])
const actor = useAct() const actor = useAct()
const animate = useAnimation() const strike = useLightning()
const onSubmit = useCallback(async ({ amount }) => { const onSubmit = useCallback(async ({ amount }) => {
if (abortSignal && zapUndoTrigger({ me, amount })) { if (abortSignal && zapUndoTrigger({ me, amount })) {
@ -111,12 +112,12 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
} }
const onPaid = () => { const onPaid = () => {
animate() strike()
onClose?.() onClose?.()
if (!me) setItemMeAnonSats({ id: item.id, amount }) if (!me) setItemMeAnonSats({ id: item.id, amount })
} }
const closeImmediately = hasSendWallet || me?.privates?.sats > Number(amount) const closeImmediately = wallets.length > 0 || me?.privates?.sats > Number(amount)
if (closeImmediately) { if (closeImmediately) {
onPaid() onPaid()
} }
@ -126,7 +127,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
id: item.id, id: item.id,
sats: Number(amount), sats: Number(amount),
act, act,
hasSendWallet hasSendWallet: wallets.length > 0
}, },
optimisticResponse: me optimisticResponse: me
? { ? {
@ -143,7 +144,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
}) })
if (error) throw error if (error) throw error
addCustomTip(Number(amount)) addCustomTip(Number(amount))
}, [me, actor, hasSendWallet, act, item.id, onClose, abortSignal, animate]) }, [me, actor, wallets.length, act, item.id, onClose, abortSignal, strike])
return act === 'BOOST' return act === 'BOOST'
? <BoostForm step={step} onSubmit={onSubmit} item={item} inputRef={inputRef} act={act}>{children}</BoostForm> ? <BoostForm step={step} onSubmit={onSubmit} item={item} inputRef={inputRef} act={act}>{children}</BoostForm>
@ -263,13 +264,13 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
// because the mutation name we use varies, // because the mutation name we use varies,
// we need to extract the result/invoice from the response // we need to extract the result/invoice from the response
const getPaidActionResult = data => Object.values(data)[0] const getPaidActionResult = data => Object.values(data)[0]
const hasSendWallet = useHasSendWallet() const wallets = useSendWallets()
const [act] = usePaidMutation(query, { const [act] = usePaidMutation(query, {
waitFor: inv => waitFor: inv =>
// if we have attached wallets, we might be paying a wrapped invoice in which case we need to make sure // if we have attached wallets, we might be paying a wrapped invoice in which case we need to make sure
// we don't prematurely consider the payment as successful (important for receiver fallbacks) // we don't prematurely consider the payment as successful (important for receiver fallbacks)
hasSendWallet wallets.length > 0
? inv?.actionState === 'PAID' ? inv?.actionState === 'PAID'
: inv?.satsReceived > 0, : inv?.satsReceived > 0,
...options, ...options,
@ -298,9 +299,9 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
} }
export function useZap () { export function useZap () {
const hasSendWallet = useHasSendWallet() const wallets = useSendWallets()
const act = useAct() const act = useAct()
const animate = useAnimation() const strike = useLightning()
const toaster = useToast() const toaster = useToast()
return useCallback(async ({ item, me, abortSignal }) => { return useCallback(async ({ item, me, abortSignal }) => {
@ -309,14 +310,14 @@ export function useZap () {
// add current sats to next tip since idempotent zaps use desired total zap not difference // add current sats to next tip since idempotent zaps use desired total zap not difference
const sats = nextTip(meSats, { ...me?.privates }) const sats = nextTip(meSats, { ...me?.privates })
const variables = { id: item.id, sats, act: 'TIP', hasSendWallet } const variables = { id: item.id, sats, act: 'TIP', hasSendWallet: wallets.length > 0 }
const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } } const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } }
try { try {
await abortSignal.pause({ me, amount: sats }) await abortSignal.pause({ me, amount: sats })
animate() strike()
// batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request // batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request
const { error } = await act({ variables, optimisticResponse, context: { batch: hasSendWallet || me?.privates?.sats > sats } }) const { error } = await act({ variables, optimisticResponse, context: { batch: wallets.length > 0 || me?.privates?.sats > sats } })
if (error) throw error if (error) throw error
} catch (error) { } catch (error) {
if (error instanceof ActCanceledError) { if (error instanceof ActCanceledError) {
@ -327,7 +328,7 @@ export function useZap () {
// but right now this toast is noisy for optimistic zaps // but right now this toast is noisy for optimistic zaps
console.error(error) console.error(error)
} }
}, [act, toaster, animate, hasSendWallet]) }, [act, toaster, strike, wallets.length])
} }
export class ActCanceledError extends Error { export class ActCanceledError extends Error {

View File

@ -191,8 +191,6 @@ export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props
comments={item.comments.comments} comments={item.comments.comments}
commentsCursor={item.comments.cursor} commentsCursor={item.comments.cursor}
fetchMoreComments={fetchMoreComments} fetchMoreComments={fetchMoreComments}
lastCommentAt={item.lastCommentAt}
item={item}
/> />
</div>} </div>}
</CarouselProvider> </CarouselProvider>

View File

@ -89,14 +89,11 @@ export default function ItemInfo ({
const myPost = (me && root && Number(me.id) === Number(root.user.id)) const myPost = (me && root && Number(me.id) === Number(root.user.id))
const rootReply = item.path.split('.').length === 2 const rootReply = item.path.split('.').length === 2
const canPin = (isPost && mySub) || (myPost && rootReply) const canPin = (isPost && mySub) || (myPost && rootReply)
const isPinnedPost = isPost && item.position && (pinnable || !item.subName)
const isPinnedSubReply = !isPost && item.position && !item.subName
const isAd = !item.parentId && Number(item.user?.id) === USER_ID.ad
const meSats = (me ? item.meSats : item.meAnonSats) || 0 const meSats = (me ? item.meSats : item.meAnonSats) || 0
return ( return (
<div className={className || `${styles.other}`}> <div className={className || `${styles.other}`}>
{!isPinnedPost && !(isPinnedSubReply && !full) && !isAd && {!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
<> <>
<span title={itemTitle(item)}> <span title={itemTitle(item)}>
{numWithUnits(item.sats)} {numWithUnits(item.sats)}
@ -110,7 +107,7 @@ export default function ItemInfo ({
</>} </>}
<Link <Link
href={`/items/${item.id}`} onClick={(e) => { href={`/items/${item.id}`} onClick={(e) => {
const viewedAt = commentsViewedAt(item.id) const viewedAt = commentsViewedAt(item)
if (viewedAt) { if (viewedAt) {
e.preventDefault() e.preventDefault()
router.push( router.push(
@ -285,7 +282,7 @@ function InfoDropdownItem ({ item }) {
) )
} }
export function PaymentInfo ({ item, disableRetry, setDisableRetry }) { function PaymentInfo ({ item, disableRetry, setDisableRetry }) {
const { me } = useMe() const { me } = useMe()
const toaster = useToast() const toaster = useToast()
const retryCreateItem = useRetryCreateItem({ id: item.id }) const retryCreateItem = useRetryCreateItem({ id: item.id })

View File

@ -13,9 +13,8 @@ import { MEDIA_URL } from '@/lib/constants'
import { abbrNum } from '@/lib/format' import { abbrNum } from '@/lib/format'
import { Badge } from 'react-bootstrap' import { Badge } from 'react-bootstrap'
import SubPopover from './sub-popover' import SubPopover from './sub-popover'
import { PaymentInfo } from './item-info'
export default function ItemJob ({ item, toc, rank, children, disableRetry, setDisableRetry }) { export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = string().email().isValidSync(item.url) const isEmail = string().email().isValidSync(item.url)
return ( return (
@ -79,7 +78,6 @@ export default function ItemJob ({ item, toc, rank, children, disableRetry, setD
<Link href={`/items/${item.id}/edit`} className='text-reset fw-bold'> <Link href={`/items/${item.id}/edit`} className='text-reset fw-bold'>
edit edit
</Link> </Link>
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
</>)} </>)}
</div> </div>
</div> </div>

View File

@ -29,7 +29,7 @@ import { useShowModal } from './modal'
import { BoostHelp } from './adv-post-form' import { BoostHelp } from './adv-post-form'
function onItemClick (e, router, item) { function onItemClick (e, router, item) {
const viewedAt = commentsViewedAt(item.id) const viewedAt = commentsViewedAt(item)
if (viewedAt) { if (viewedAt) {
e.preventDefault() e.preventDefault()
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {

View File

@ -45,18 +45,18 @@ function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
) )
} }
function LightningExplainer ({ text, children, backButton, md = 12, lg = 6 }) { function LightningExplainer ({ text, children }) {
const router = useRouter() const router = useRouter()
return ( return (
<Container> <Container>
<div className={styles.login}> <div className={styles.login}>
{backButton && <div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>} <div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>
<h3 className='w-100 pb-2'> <h3 className='w-100 pb-2'>
{text || 'Login'} with Lightning {text || 'Login'} with Lightning
</h3> </h3>
<div className='fw-bold text-muted pb-4'>This is the most private way to use Stacker News. Just open your Lightning wallet and scan the QR code.</div> <div className='fw-bold text-muted pb-4'>This is the most private way to use Stacker News. Just open your Lightning wallet and scan the QR code.</div>
<Row className='w-100 text-muted'> <Row className='w-100 text-muted'>
<Col className='ps-0 mb-4' md={md} lg={lg}> <Col className='ps-0 mb-4' md>
<AccordianItem <AccordianItem
header={`Which wallets can I use to ${(text || 'Login').toLowerCase()}?`} header={`Which wallets can I use to ${(text || 'Login').toLowerCase()}?`}
body={ body={
@ -92,7 +92,7 @@ function LightningExplainer ({ text, children, backButton, md = 12, lg = 6 }) {
} }
/> />
</Col> </Col>
<Col md={md} lg={lg} className='mx-auto' style={{ maxWidth: '300px' }}> <Col md className='mx-auto' style={{ maxWidth: '300px' }}>
{children} {children}
</Col> </Col>
</Row> </Row>
@ -101,9 +101,9 @@ function LightningExplainer ({ text, children, backButton, md = 12, lg = 6 }) {
) )
} }
export function LightningAuthWithExplainer ({ text, callbackUrl, multiAuth, backButton = true, md = 12, lg = 6 }) { export function LightningAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
return ( return (
<LightningExplainer text={text} backButton={backButton} md={md} lg={lg}> <LightningExplainer text={text}>
<LightningAuth callbackUrl={callbackUrl} multiAuth={multiAuth} /> <LightningAuth callbackUrl={callbackUrl} multiAuth={multiAuth} />
</LightningExplainer> </LightningExplainer>
) )

View File

@ -13,11 +13,16 @@ export class LightningProvider extends React.Component {
* @returns boolean indicating whether the strike actually happened, based on user preferences * @returns boolean indicating whether the strike actually happened, based on user preferences
*/ */
strike = () => { strike = () => {
this.setState(state => { const should = window.localStorage.getItem('lnAnimate') || 'yes'
return { if (should === 'yes') {
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />] this.setState(state => {
} return {
}) bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
}
})
return true
}
return false
} }
unstrike = (index) => { unstrike = (index) => {

View File

@ -35,12 +35,3 @@
.linkBoxParent img { .linkBoxParent img {
pointer-events: auto !important; pointer-events: auto !important;
} }
.linkBoxParent h1 a,
.linkBoxParent h2 a,
.linkBoxParent h3 a,
.linkBoxParent h4 a,
.linkBoxParent h5 a,
.linkBoxParent h6 a {
pointer-events: none !important;
}

62
components/log-message.js Normal file
View File

@ -0,0 +1,62 @@
import { timeSince } from '@/lib/time'
import styles from '@/styles/log.module.css'
import { Fragment, useState } from 'react'
export default function LogMessage ({ showWallet, wallet, level, message, context, ts }) {
const [show, setShow] = useState(false)
let className
switch (level.toLowerCase()) {
case 'ok':
case 'success':
level = 'ok'
className = 'text-success'; break
case 'error':
className = 'text-danger'; break
case 'warn':
className = 'text-warning'; break
default:
className = 'text-info'
}
const filtered = context
? Object.keys(context)
.filter(key => !['send', 'recv', 'status'].includes(key))
.reduce((obj, key) => {
obj[key] = context[key]
return obj
}, {})
: {}
const hasContext = context && Object.keys(filtered).length > 0
const handleClick = () => {
if (hasContext) { setShow(show => !show) }
}
const style = hasContext ? { cursor: 'pointer' } : { cursor: 'inherit' }
const indicator = hasContext ? (show ? '-' : '+') : <></>
return (
<>
<tr className={styles.tableRow} onClick={handleClick} style={style}>
<td className={styles.timestamp}>{timeSince(new Date(ts))}</td>
{showWallet ? <td className={styles.wallet}>[{wallet}]</td> : <td className='mx-1' />}
<td className={`${styles.level} ${className}`}>{level}</td>
<td>{message}</td>
<td>{indicator}</td>
</tr>
{show && hasContext && Object.entries(filtered)
.map(([key, value], i) => {
const last = i === Object.keys(filtered).length - 1
return (
<tr className={styles.line} key={i}>
<td />
<td className={last ? 'pb-2 pe-1' : 'pe-1'} colSpan='2'>{key}</td>
<td className={last ? 'text-break pb-2' : 'text-break'}>{value}</td>
</tr>
)
})}
</>
)
}

119
components/logger.js Normal file
View File

@ -0,0 +1,119 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useMe } from './me'
import fancyNames from '@/lib/fancy-names.json'
const generateFancyName = () => {
// 100 adjectives * 100 nouns * 10000 = 100M possible names
const pickRandom = (array) => array[Math.floor(Math.random() * array.length)]
const adj = pickRandom(fancyNames.adjectives)
const noun = pickRandom(fancyNames.nouns)
const id = Math.floor(Math.random() * fancyNames.maxSuffix)
return `${adj}-${noun}-${id}`
}
export function detectOS () {
if (!window.navigator) return ''
const userAgent = window.navigator.userAgent
const platform = window.navigator.userAgentData?.platform || window.navigator.platform
const macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K']
const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE']
const iosPlatforms = ['iPhone', 'iPad', 'iPod']
let os = null
if (macosPlatforms.indexOf(platform) !== -1) {
os = 'Mac OS'
} else if (iosPlatforms.indexOf(platform) !== -1) {
os = 'iOS'
} else if (windowsPlatforms.indexOf(platform) !== -1) {
os = 'Windows'
} else if (/Android/.test(userAgent)) {
os = 'Android'
} else if (/Linux/.test(platform)) {
os = 'Linux'
}
return os
}
export const LoggerContext = createContext()
export const LoggerProvider = ({ children }) => {
return (
<ServiceWorkerLoggerProvider>
{children}
</ServiceWorkerLoggerProvider>
)
}
const ServiceWorkerLoggerContext = createContext()
function ServiceWorkerLoggerProvider ({ children }) {
const { me } = useMe()
const [name, setName] = useState()
const [os, setOS] = useState()
useEffect(() => {
let name = window.localStorage.getItem('fancy-name')
if (!name) {
name = generateFancyName()
window.localStorage.setItem('fancy-name', name)
}
setName(name)
setOS(detectOS())
}, [])
const log = useCallback(level => {
return async (message, context) => {
if (!me || !me.privates?.diagnostics) return
const env = {
userAgent: window.navigator.userAgent,
// os may not be initialized yet
os: os || detectOS()
}
const body = {
level,
env,
// name may be undefined if it wasn't stored in local storage yet
// we fallback to local storage since on page reloads, the name may wasn't fetched from local storage yet
name: name || window.localStorage.getItem('fancy-name'),
message,
context
}
await fetch('/api/log', {
method: 'post',
headers: {
'Content-type': 'application/json'
},
body: JSON.stringify(body)
}).catch(console.error)
}
}, [me?.privates?.diagnostics, name, os])
const logger = useMemo(() => ({
info: log('info'),
warn: log('warn'),
error: log('error'),
name
}), [log, name])
useEffect(() => {
// for communication between app and service worker
const channel = new MessageChannel()
navigator?.serviceWorker?.controller?.postMessage({ action: 'MESSAGE_PORT' }, [channel.port2])
channel.port1.onmessage = (event) => {
const { message, level, context } = Object.assign({ level: 'info' }, event.data)
logger[level](message, context)
}
}, [logger])
return (
<ServiceWorkerLoggerContext.Provider value={logger}>
{children}
</ServiceWorkerLoggerContext.Provider>
)
}
export function useServiceWorkerLogger () {
return useContext(ServiceWorkerLoggerContext)
}

View File

@ -12,7 +12,6 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import * as cookie from 'cookie' import * as cookie from 'cookie'
import { cookieOptions } from '@/lib/auth' import { cookieOptions } from '@/lib/auth'
import Link from 'next/link'
export function EmailLoginForm ({ text, callbackUrl, multiAuth }) { export function EmailLoginForm ({ text, callbackUrl, multiAuth }) {
const disabled = multiAuth const disabled = multiAuth
@ -53,26 +52,12 @@ const authErrorMessages = {
default: 'Auth failed. Try again or choose a different method.' default: 'Auth failed. Try again or choose a different method.'
} }
export function authErrorMessage (error, signin) { export function authErrorMessage (error) {
if (!error) return null return error && (authErrorMessages[error] ?? authErrorMessages.default)
const message = error && (authErrorMessages[error] ?? authErrorMessages.default)
// workaround for signin/signup awareness due to missing support from next-auth
if (signin) {
return (
<>
{message}
<br />
If you are new to Stacker News, please <Link className='fw-bold' href='/signup'>sign up</Link> first.
</>
)
}
return message
} }
export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer, signin }) { export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer, signin }) {
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error, signin)) const [errorMessage, setErrorMessage] = useState(authErrorMessage(error))
const router = useRouter() const router = useRouter()
// signup/signin awareness cookie // signup/signin awareness cookie

View File

@ -4,12 +4,6 @@ import BackArrow from '@/svgs/arrow-left-line.svg'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import ActionDropdown from './action-dropdown' import ActionDropdown from './action-dropdown'
export class ModalClosedError extends Error {
constructor () {
super('modal closed')
}
}
export const ShowModalContext = createContext(() => null) export const ShowModalContext = createContext(() => null)
export function ShowModalProvider ({ children }) { export function ShowModalProvider ({ children }) {

View File

@ -7,24 +7,24 @@ import { useCallback, useEffect, useState } from 'react'
import Price from '../price' import Price from '../price'
import SubSelect from '../sub-select' import SubSelect from '../sub-select'
import { USER_ID } from '../../lib/constants' import { USER_ID } from '../../lib/constants'
import Head from 'next/head'
import NoteIcon from '../../svgs/notification-4-fill.svg' import NoteIcon from '../../svgs/notification-4-fill.svg'
import { useMe } from '../me' import { useMe } from '../me'
import { abbrNum } from '../../lib/format' import { abbrNum } from '../../lib/format'
import { useServiceWorker } from '../serviceworker' import { useServiceWorker } from '../serviceworker'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import Badges from '../badge' import Badges from '../badge'
import { randInRange } from '../../lib/rand'
import { useLightning } from '../lightning'
import LightningIcon from '../../svgs/bolt.svg' import LightningIcon from '../../svgs/bolt.svg'
import SearchIcon from '../../svgs/search-line.svg' import SearchIcon from '../../svgs/search-line.svg'
import classNames from 'classnames' import classNames from 'classnames'
import SnIcon from '@/svgs/sn.svg' import SnIcon from '@/svgs/sn.svg'
import { useHasNewNotes } from '../use-has-new-notes' import { useHasNewNotes } from '../use-has-new-notes'
// import { useWallets } from '@/wallets/client/hooks' import { useWallets } from '@/wallets/index'
import { useWalletIndicator } from '@/wallets/client/hooks' import SwitchAccountList, { useAccounts } from '@/components/account'
import SwitchAccountList, { nextAccount, useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal' import { useShowModal } from '@/components/modal'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import Head from 'next/head'
export function Brand ({ className }) { export function Brand ({ className }) {
return ( return (
<Link href='/' passHref legacyBehavior> <Link href='/' passHref legacyBehavior>
@ -164,34 +164,8 @@ export function NavWalletSummary ({ className }) {
) )
} }
export const Indicator = ({ superscript }) => {
if (superscript) {
return (
<span className='d-inline-block p-1'>
<span
className='position-absolute p-1 bg-secondary'
style={{ top: '5px', right: '0px', height: '5px', width: '5px' }}
>
<span className='invisible'>{' '}</span>
</span>
</span>
)
}
return (
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
</div>
)
}
export function MeDropdown ({ me, dropNavKey }) { export function MeDropdown ({ me, dropNavKey }) {
if (!me) return null if (!me) return null
const profileIndicator = !me.bioId
const walletIndicator = useWalletIndicator()
const indicator = profileIndicator || walletIndicator
return ( return (
<div className=''> <div className=''>
<Dropdown className={styles.dropdown} align='end'> <Dropdown className={styles.dropdown} align='end'>
@ -199,7 +173,12 @@ export function MeDropdown ({ me, dropNavKey }) {
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
<Nav.Link eventKey={me.name} as='span' className='p-0 position-relative'> <Nav.Link eventKey={me.name} as='span' className='p-0 position-relative'>
{`@${me.name}`} {`@${me.name}`}
{indicator && <Indicator superscript />} {!me.bioId &&
<span className='d-inline-block p-1'>
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px', height: '5px', width: '5px' }}>
<span className='invisible'>{' '}</span>
</span>
</span>}
</Nav.Link> </Nav.Link>
<Badges user={me} /> <Badges user={me} />
</div> </div>
@ -208,17 +187,17 @@ export function MeDropdown ({ me, dropNavKey }) {
<Link href={'/' + me.name} passHref legacyBehavior> <Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}> <Dropdown.Item active={me.name === dropNavKey}>
profile profile
{profileIndicator && <Indicator />} {me && !me.bioId &&
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
</div>}
</Dropdown.Item> </Dropdown.Item>
</Link> </Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior> <Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item> <Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
</Link> </Link>
<Link href='/wallets' passHref legacyBehavior> <Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'> <Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
wallets
{walletIndicator && <Indicator />}
</Dropdown.Item>
</Link> </Link>
<Link href='/credits' passHref legacyBehavior> <Link href='/credits' passHref legacyBehavior>
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item> <Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
@ -293,7 +272,8 @@ export default function LoginButton () {
function LogoutObstacle ({ onClose }) { function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker() const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
// const { removeLocalWallets } = useWallets() const { removeLocalWallets } = useWallets()
const { nextAccount } = useAccounts()
const router = useRouter() const router = useRouter()
return ( return (
@ -324,6 +304,8 @@ function LogoutObstacle ({ onClose }) {
await togglePushSubscription().catch(console.error) await togglePushSubscription().catch(console.error)
} }
removeLocalWallets()
await signOut({ callbackUrl: '/' }) await signOut({ callbackUrl: '/' })
}} }}
> >
@ -358,7 +340,7 @@ export function LogoutDropdownItem ({ handleClose }) {
function SwitchAccountButton ({ handleClose }) { function SwitchAccountButton ({ handleClose }) {
const showModal = useShowModal() const showModal = useShowModal()
const accounts = useAccounts() const { accounts } = useAccounts()
if (accounts.length === 0) return null if (accounts.length === 0) return null
@ -396,6 +378,18 @@ export function LoginButtons ({ handleClose }) {
} }
export function AnonDropdown ({ path }) { export function AnonDropdown ({ path }) {
const strike = useLightning()
useEffect(() => {
if (!window.localStorage.getItem('striked')) {
const to = setTimeout(() => {
strike()
window.localStorage.setItem('striked', 'yep')
}, randInRange(3000, 10000))
return () => clearTimeout(to)
}
}, [])
return ( return (
<div className='position-relative'> <div className='position-relative'>
<Dropdown className={styles.dropdown} align='end' autoClose> <Dropdown className={styles.dropdown} align='end' autoClose>

View File

@ -2,12 +2,11 @@ import { useState } from 'react'
import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap' import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap'
import { MEDIA_URL } from '@/lib/constants' import { MEDIA_URL } from '@/lib/constants'
import Link from 'next/link' import Link from 'next/link'
import { Indicator, LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common' import { LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
import AnonIcon from '@/svgs/spy-fill.svg' import AnonIcon from '@/svgs/spy-fill.svg'
import styles from './footer.module.css' import styles from './footer.module.css'
import canvasStyles from './offcanvas.module.css' import canvasStyles from './offcanvas.module.css'
import classNames from 'classnames' import classNames from 'classnames'
import { useWalletIndicator } from '@/wallets/client/hooks'
export default function OffCanvas ({ me, dropNavKey }) { export default function OffCanvas ({ me, dropNavKey }) {
const [show, setShow] = useState(false) const [show, setShow] = useState(false)
@ -26,9 +25,6 @@ export default function OffCanvas ({ me, dropNavKey }) {
) )
: <span className='text-muted pointer'><AnonIcon onClick={onClick} width='22' height='22' /></span> : <span className='text-muted pointer'><AnonIcon onClick={onClick} width='22' height='22' /></span>
const profileIndicator = me && !me.bioId
const walletIndicator = useWalletIndicator()
return ( return (
<> <>
<MeImage onClick={handleShow} /> <MeImage onClick={handleShow} />
@ -54,17 +50,17 @@ export default function OffCanvas ({ me, dropNavKey }) {
<Link href={'/' + me.name} passHref legacyBehavior> <Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}> <Dropdown.Item active={me.name === dropNavKey}>
profile profile
{profileIndicator && <Indicator />} {me && !me.bioId &&
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
</div>}
</Dropdown.Item> </Dropdown.Item>
</Link> </Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior> <Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item> <Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
</Link> </Link>
<Link href='/wallets' passHref legacyBehavior> <Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'> <Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
wallets
{walletIndicator && <Indicator />}
</Dropdown.Item>
</Link> </Link>
<Link href='/credits' passHref legacyBehavior> <Link href='/credits' passHref legacyBehavior>
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item> <Dropdown.Item eventKey='credits'>credits</Dropdown.Item>

View File

@ -58,9 +58,7 @@ function Notification ({ n, fresh }) {
(type === 'InvoicePaid' && (n.invoice.nostr ? <NostrZap n={n} /> : <InvoicePaid n={n} />)) || (type === 'InvoicePaid' && (n.invoice.nostr ? <NostrZap n={n} /> : <InvoicePaid n={n} />)) ||
(type === 'WithdrawlPaid' && <WithdrawlPaid n={n} />) || (type === 'WithdrawlPaid' && <WithdrawlPaid n={n} />) ||
(type === 'Referral' && <Referral n={n} />) || (type === 'Referral' && <Referral n={n} />) ||
(type === 'CowboyHat' && <CowboyHat n={n} />) || (type === 'Streak' && <Streak n={n} />) ||
(['NewHorse', 'LostHorse'].includes(type) && <Horse n={n} />) ||
(['NewGun', 'LostGun'].includes(type) && <Gun n={n} />) ||
(type === 'Votification' && <Votification n={n} />) || (type === 'Votification' && <Votification n={n} />) ||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) || (type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
(type === 'Mention' && <Mention n={n} />) || (type === 'Mention' && <Mention n={n} />) ||
@ -114,14 +112,12 @@ function NoteHeader ({ color, children, big }) {
function NoteItem ({ item, ...props }) { function NoteItem ({ item, ...props }) {
return ( return (
<div> <div>
{item.isJob {item.title
? <ItemJob item={item} {...props} /> ? <Item item={item} itemClassName='pt-0' {...props} />
: item.title : (
? <Item item={item} itemClassName='pt-0' {...props} /> <RootProvider root={item.root}>
: ( <Comment item={item} noReply includeParent clickToContext {...props} />
<RootProvider root={item.root}> </RootProvider>)}
<Comment item={item} noReply includeParent clickToContext {...props} />
</RootProvider>)}
</div> </div>
) )
} }
@ -167,7 +163,7 @@ const defaultOnClick = n => {
if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` } if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` }
if (type === 'Referral') return { href: '/referrals/month' } if (type === 'Referral') return { href: '/referrals/month' }
if (type === 'ReferralReward') return { href: '/referrals/month' } if (type === 'ReferralReward') return { href: '/referrals/month' }
if (['CowboyHat', 'NewHorse', 'LostHorse', 'NewGun', 'LostGun'].includes(type)) return {} if (type === 'Streak') return {}
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` } if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
if (!n.item) return {} if (!n.item) return {}
@ -176,64 +172,30 @@ const defaultOnClick = n => {
return itemLink(n.item) return itemLink(n.item)
} }
function blurb (n) { function Streak ({ n }) {
const type = n.__typename === 'CowboyHat' function blurb (n) {
? 'COWBOY_HAT' const type = n.type ?? 'COWBOY_HAT'
: (n.__typename.includes('Horse') ? 'HORSE' : 'GUN') const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length)
const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length) if (n.days) {
const lost = n.days || n.__typename.includes('Lost') return `After ${numWithUnits(n.days, {
return lost ? LOST_BLURBS[type][index] : FOUND_BLURBS[type][index] abbreviate: false,
} unitSingular: 'day',
unitPlural: 'days'
})}, ` + LOST_BLURBS[type][index]
}
function CowboyHat ({ n }) { return FOUND_BLURBS[type][index]
const Icon = n.days ? BaldIcon : CowboyHatIcon
let body = ''
if (n.days) {
body = `After ${numWithUnits(n.days, {
abbreviate: false,
unitSingular: 'day',
unitPlural: 'days'
})}, `
} }
body += `you ${n.days ? 'lost your' : 'found a'} cowboy hat` const Icon = n.days
? n.type === 'GUN' ? HolsterIcon : n.type === 'HORSE' ? SaddleIcon : BaldIcon
: n.type === 'GUN' ? GunIcon : n.type === 'HORSE' ? HorseIcon : CowboyHatIcon
return ( return (
<div className='d-flex'> <div className='d-flex'>
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div> <div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
<div className='ms-1 p-1'> <div className='ms-1 p-1'>
<span className='fw-bold'>{body}</span> <span className='fw-bold'>you {n.days ? 'lost your' : 'found a'} {n.type.toLowerCase().replace('_', ' ')}</span>
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div>
</div>
)
}
function Horse ({ n }) {
const found = n.__typename.includes('New')
const Icon = found ? HorseIcon : SaddleIcon
return (
<div className='d-flex'>
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
<div className='ms-1 p-1'>
<span className='fw-bold'>you {found ? 'found a' : 'lost your'} horse</span>
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div>
</div>
)
}
function Gun ({ n }) {
const found = n.__typename.includes('New')
const Icon = found ? GunIcon : HolsterIcon
return (
<div className='d-flex'>
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
<div className='ms-1 p-1'>
<span className='fw-bold'>you {found ? 'found a' : 'lost your'} gun</span>
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div> <div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div> </div>
</div> </div>
@ -738,7 +700,7 @@ function Reminder ({ n }) {
return ( return (
<> <>
<NoteHeader color='info'> <NoteHeader color='info'>
you requested this reminder you asked to be reminded of this {n.item.title ? 'post' : 'comment'}
</NoteHeader> </NoteHeader>
<NoteItem item={n.item} /> <NoteItem item={n.item} />
</> </>

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import Button from 'react-bootstrap/Button'
import styles from './pay-bounty.module.css' import styles from './pay-bounty.module.css'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
import { useMe } from './me' import { useMe } from './me'
@ -6,10 +7,9 @@ import { numWithUnits } from '@/lib/format'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { useRoot } from './root' import { useRoot } from './root'
import { ActCanceledError, useAct } from './item-act' import { ActCanceledError, useAct } from './item-act'
import { useAnimation } from '@/components/animation' import { useLightning } from './lightning'
import { useToast } from './toast' import { useToast } from './toast'
import { useHasSendWallet } from '@/wallets/client/hooks' import { useSendWallets } from '@/wallets/index'
import { Form, SubmitButton } from './form'
export const payBountyCacheMods = { export const payBountyCacheMods = {
onPaid: (cache, { data }) => { onPaid: (cache, { data }) => {
@ -48,11 +48,11 @@ export default function PayBounty ({ children, item }) {
const { me } = useMe() const { me } = useMe()
const showModal = useShowModal() const showModal = useShowModal()
const root = useRoot() const root = useRoot()
const animate = useAnimation() const strike = useLightning()
const toaster = useToast() const toaster = useToast()
const hasSendWallet = useHasSendWallet() const wallets = useSendWallets()
const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet } const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet: wallets.length > 0 }
const act = useAct({ const act = useAct({
variables, variables,
optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } }, optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } },
@ -61,7 +61,7 @@ export default function PayBounty ({ children, item }) {
const handlePayBounty = async onCompleted => { const handlePayBounty = async onCompleted => {
try { try {
animate() strike()
const { error } = await act({ onCompleted }) const { error } = await act({ onCompleted })
if (error) throw error if (error) throw error
} catch (error) { } catch (error) {
@ -90,12 +90,11 @@ export default function PayBounty ({ children, item }) {
<div className='text-center fw-bold text-muted'> <div className='text-center fw-bold text-muted'>
Pay this bounty to {item.user.name}? Pay this bounty to {item.user.name}?
</div> </div>
{/* initial={{ id: item.id }} is a hack to allow SubmitButton to be used as a button */} <div className='text-center'>
<Form className='text-center' onSubmit={() => handlePayBounty(onClose)} initial={{ id: item.id }}> <Button className='mt-4' variant='primary' onClick={() => handlePayBounty(onClose)}>
<SubmitButton className='mt-4' variant='primary' submittingText='paying...' appendText={numWithUnits(root.bounty)}> pay <small>{numWithUnits(root.bounty)}</small>
pay </Button>
</SubmitButton> </div>
</Form>
</> </>
)) ))
}} }}

View File

@ -1,4 +1,4 @@
import { Checkbox, DateTimeInput, Form, Input, MarkdownInput, VariableInput } from '@/components/form' import { DateTimeInput, Form, Input, MarkdownInput, VariableInput } from '@/components/form'
import { useApolloClient } from '@apollo/client' import { useApolloClient } from '@apollo/client'
import Countdown from './countdown' import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form' import AdvPostForm, { AdvPostInitial } from './adv-post-form'
@ -30,7 +30,6 @@ export function PollForm ({ item, sub, editThreshold, children }) {
text: item?.text || '', text: item?.text || '',
options: initialOptions || ['', ''], options: initialOptions || ['', ''],
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting, crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
randPollOptions: item?.poll?.randPollOptions || false,
pollExpiresAt: item ? item.pollExpiresAt : datePivot(new Date(), { hours: 25 }), pollExpiresAt: item ? item.pollExpiresAt : datePivot(new Date(), { hours: 25 }),
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }), ...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
@ -69,11 +68,6 @@ export function PollForm ({ item, sub, editThreshold, children }) {
label='poll expiration' label='poll expiration'
name='pollExpiresAt' name='pollExpiresAt'
className='pr-4' className='pr-4'
groupClassName='mb-0'
/>
<Checkbox
label={<div className='d-flex align-items-center'>randomize order of poll choices</div>}
name='randPollOptions'
/> />
</AdvPostForm> </AdvPostForm>
<ItemButtonBar itemId={item?.id} /> <ItemButtonBar itemId={item?.id} />

View File

@ -110,7 +110,7 @@ export function PostForm ({ type, sub, children }) {
noForm noForm
size='medium' size='medium'
sub={sub?.name} sub={sub?.name}
info={sub && <TerritoryInfo sub={sub} includeLink />} info={sub && <TerritoryInfo sub={sub} />}
hint={sub?.moderated && 'this territory is moderated'} hint={sub?.moderated && 'this territory is moderated'}
/> />
<div> <div>
@ -176,7 +176,7 @@ export default function Post ({ sub }) {
className='d-flex' className='d-flex'
size='medium' size='medium'
label='territory' label='territory'
info={sub && <TerritoryInfo sub={sub} includeLink />} info={sub && <TerritoryInfo sub={sub} />}
hint={sub?.moderated && 'this territory is moderated'} hint={sub?.moderated && 'this territory is moderated'}
/>} />}
</PostForm> </PostForm>

View File

@ -1,53 +0,0 @@
export default function preserveScroll (callback) {
// preserve the actual scroll position
const scrollTop = window.scrollY
// if the scroll position is at the top, we don't need to preserve it, just call the callback
if (scrollTop <= 0) {
callback()
return
}
// get a reference element at the center of the viewport to track if content is added above it
const ref = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
const refTop = ref ? ref.getBoundingClientRect().top + scrollTop : scrollTop
// observe the document for changes in height
const observer = new window.MutationObserver(() => {
// request animation frame to ensure the DOM is updated
window.requestAnimationFrame(() => {
// we can't proceed if we couldn't find a traceable reference element
if (!ref) {
cleanup()
return
}
// get the new position of the reference element along with the new scroll position
const newRefTop = ref ? ref.getBoundingClientRect().top + window.scrollY : window.scrollY
// has the reference element moved?
const refMoved = newRefTop - refTop
// if the reference element moved, we need to scroll to the new position
if (refMoved > 0) {
window.scrollTo({
// some browsers don't respond well to fractional scroll position, so we round up the new position to the nearest integer
top: scrollTop + Math.ceil(refMoved),
behavior: 'instant'
})
}
cleanup()
})
})
const timeout = setTimeout(() => cleanup(), 1000) // fallback
function cleanup () {
clearTimeout(timeout)
observer.disconnect()
}
observer.observe(document.body, { childList: true, subtree: true })
callback()
}

View File

@ -13,7 +13,6 @@ import { useRoot } from './root'
import { CREATE_COMMENT } from '@/fragments/paidAction' import { CREATE_COMMENT } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit' import useItemSubmit from './use-item-submit'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { updateAncestorsCommentCount } from '@/lib/comments'
export default forwardRef(function Reply ({ export default forwardRef(function Reply ({
item, item,
@ -83,7 +82,17 @@ export default forwardRef(function Reply ({
const ancestors = item.path.split('.') const ancestors = item.path.split('.')
// update all ancestors // update all ancestors
updateAncestorsCommentCount(cache, ancestors, 1) ancestors.forEach(id => {
cache.modify({
id: `Item:${id}`,
fields: {
ncomments (existingNComments = 0) {
return existingNComments + 1
}
},
optimistic: true
})
})
// so that we don't see indicator for our own comments, we record this comments as the latest time // so that we don't see indicator for our own comments, we record this comments as the latest time
// but we also have record num comments, in case someone else commented when we did // but we also have record num comments, in case someone else commented when we did

View File

@ -1,26 +1,22 @@
import Container from 'react-bootstrap/Container' import Container from 'react-bootstrap/Container'
import styles from './search.module.css' import styles from './search.module.css'
import SearchIcon from '@/svgs/search-line.svg' import SearchIcon from '@/svgs/search-line.svg'
import { useEffect, useMemo, useRef, useState, useCallback } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import { Form, Input, Select, DatePicker, SubmitButton } from './form'
Form,
Input,
Select,
DatePicker,
SubmitButton,
useDualAutocomplete,
DualAutocompleteWrapper
} from './form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { whenToFrom } from '@/lib/time' import { whenToFrom } from '@/lib/time'
import { useMe } from './me' import { useMe } from './me'
import { useField } from 'formik'
export default function Search ({ sub }) { export default function Search ({ sub }) {
const router = useRouter() const router = useRouter()
const [q, setQ] = useState(router.query.q || '') const [q, setQ] = useState(router.query.q || '')
const inputRef = useRef(null)
const { me } = useMe() const { me } = useMe()
useEffect(() => {
inputRef.current?.focus()
}, [])
const search = async values => { const search = async values => {
let prefix = '' let prefix = ''
if (sub) { if (sub) {
@ -67,13 +63,18 @@ export default function Search ({ sub }) {
onSubmit={values => search({ ...values })} onSubmit={values => search({ ...values })}
> >
<div className={`${styles.active} mb-3`}> <div className={`${styles.active} mb-3`}>
<SearchInput <Input
name='q' name='q'
required required
autoFocus autoFocus
groupClassName='me-3 mb-0 flex-grow-1' groupClassName='me-3 mb-0 flex-grow-1'
className='flex-grow-1' className='flex-grow-1'
setOuterQ={setQ} clear
innerRef={inputRef}
overrideValue={q}
onChange={async (formik, e) => {
setQ(e.target.value?.trim())
}}
/> />
<SubmitButton variant='primary' className={styles.search}> <SubmitButton variant='primary' className={styles.search}>
<SearchIcon width={22} height={22} /> <SearchIcon width={22} height={22} />
@ -134,52 +135,3 @@ export default function Search ({ sub }) {
</> </>
) )
} }
function SearchInput ({ name, setOuterQ, ...props }) {
const [, meta, helpers] = useField(name)
const inputRef = useRef(null)
useEffect(() => {
if (meta.value !== undefined) setOuterQ(meta.value.trim())
}, [meta.value, setOuterQ])
const setCaret = useCallback(({ start, end }) => {
inputRef.current?.setSelectionRange(start, end)
}, [])
const { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({
meta,
helpers,
innerRef: inputRef,
setSelectionRange: setCaret
})
const handleChangeWithOuter = useCallback((formik, e) => {
setOuterQ(e.target.value.trim())
handleTextChange(e)
}, [setOuterQ, handleTextChange])
return (
<div className='position-relative flex-grow-1'>
<DualAutocompleteWrapper
userAutocomplete={userAutocomplete}
territoryAutocomplete={territoryAutocomplete}
>
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => (
<Input
name={name}
innerRef={inputRef}
clear
autoComplete='off'
onChange={handleChangeWithOuter}
onKeyDown={(e) => {
handleKeyDown(e, userSuggestOnKeyDown, territorySuggestOnKeyDown)
}}
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)}
{...props}
/>
)}
</DualAutocompleteWrapper>
</div>
)
}

View File

@ -1,16 +1,20 @@
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react' import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
import { Workbox } from 'workbox-window' import { Workbox } from 'workbox-window'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import { requestPersistentStorage } from './use-indexeddb' import { detectOS, useServiceWorkerLogger } from './logger'
const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY
const ServiceWorkerContext = createContext() const ServiceWorkerContext = createContext()
// message types for communication between app and service worker // message types for communication between app and service worker
export const DELETE_SUBSCRIPTION = 'DELETE_SUBSCRIPTION' export const MESSAGE_PORT = 'MESSAGE_PORT' // message to exchange message channel on which service worker will send messages back to app
export const STORE_SUBSCRIPTION = 'STORE_SUBSCRIPTION' export const ACTION_PORT = 'ACTION_PORT' // message to exchange action channel on which service worker will send actions back to app
export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS' export const SYNC_SUBSCRIPTION = 'SYNC_SUBSCRIPTION' // trigger onPushSubscriptionChange event in service worker manually
export const RESUBSCRIBE = 'RESUBSCRIBE' // trigger resubscribing to push notifications (sw -> app)
export const DELETE_SUBSCRIPTION = 'DELETE_SUBSCRIPTION' // delete subscription in IndexedDB (app -> sw)
export const STORE_SUBSCRIPTION = 'STORE_SUBSCRIPTION' // store subscription in IndexedDB (app -> sw)
export const STORE_OS = 'STORE_OS' // store OS in service worker
export const ServiceWorkerProvider = ({ children }) => { export const ServiceWorkerProvider = ({ children }) => {
const [registration, setRegistration] = useState(null) const [registration, setRegistration] = useState(null)
@ -34,12 +38,13 @@ export const ServiceWorkerProvider = ({ children }) => {
`) `)
const [deletePushSubscription] = useMutation( const [deletePushSubscription] = useMutation(
gql` gql`
mutation deletePushSubscription($endpoint: String!) { mutation deletePushSubscription($endpoint: String!) {
deletePushSubscription(endpoint: $endpoint) { deletePushSubscription(endpoint: $endpoint) {
id id
}
} }
} `)
`) const logger = useServiceWorkerLogger()
// I am not entirely sure if this is needed since at least in Brave, // I am not entirely sure if this is needed since at least in Brave,
// using `registration.pushManager.subscribe` also prompts the user. // using `registration.pushManager.subscribe` also prompts the user.
@ -72,6 +77,7 @@ export const ServiceWorkerProvider = ({ children }) => {
// see https://stackoverflow.com/a/69624651 // see https://stackoverflow.com/a/69624651
let pushSubscription = await registration.pushManager.subscribe(subscribeOptions) let pushSubscription = await registration.pushManager.subscribe(subscribeOptions)
const { endpoint } = pushSubscription const { endpoint } = pushSubscription
logger.info('subscribed to push notifications', { endpoint })
// convert keys from ArrayBuffer to string // convert keys from ArrayBuffer to string
pushSubscription = JSON.parse(JSON.stringify(pushSubscription)) pushSubscription = JSON.parse(JSON.stringify(pushSubscription))
// Send subscription to service worker to save it so we can use it later during `pushsubscriptionchange` // Send subscription to service worker to save it so we can use it later during `pushsubscriptionchange`
@ -80,8 +86,7 @@ export const ServiceWorkerProvider = ({ children }) => {
action: STORE_SUBSCRIPTION, action: STORE_SUBSCRIPTION,
subscription: pushSubscription subscription: pushSubscription
}) })
requestPersistentStorage() logger.info('sent STORE_SUBSCRIPTION to service worker', { endpoint })
// send subscription to server // send subscription to server
const variables = { const variables = {
endpoint, endpoint,
@ -89,21 +94,34 @@ export const ServiceWorkerProvider = ({ children }) => {
auth: pushSubscription.keys.auth auth: pushSubscription.keys.auth
} }
await savePushSubscription({ variables }) await savePushSubscription({ variables })
logger.info('sent push subscription to server', { endpoint })
} }
const unsubscribeFromPushNotifications = async (subscription) => { const unsubscribeFromPushNotifications = async (subscription) => {
await subscription.unsubscribe() await subscription.unsubscribe()
const { endpoint } = subscription const { endpoint } = subscription
logger.info('unsubscribed from push notifications', { endpoint })
await deletePushSubscription({ variables: { endpoint } }) await deletePushSubscription({ variables: { endpoint } })
// also delete push subscription in IndexedDB so we can tell if the user disabled push subscriptions
// or we lost the push subscription due to a bug
navigator.serviceWorker.controller.postMessage({ action: DELETE_SUBSCRIPTION }) navigator.serviceWorker.controller.postMessage({ action: DELETE_SUBSCRIPTION })
logger.info('deleted push subscription from server', { endpoint })
} }
const togglePushSubscription = useCallback(async () => { const togglePushSubscription = useCallback(async () => {
const pushSubscription = await registration.pushManager.getSubscription() const pushSubscription = await registration.pushManager.getSubscription()
if (pushSubscription) { if (pushSubscription) {
return await unsubscribeFromPushNotifications(pushSubscription) return unsubscribeFromPushNotifications(pushSubscription)
} }
await subscribeToPushNotifications() return subscribeToPushNotifications().then(async () => {
// request persistent storage: https://web.dev/learn/pwa/offline-data#data_persistence
const persisted = await navigator?.storage?.persisted?.()
if (!persisted && navigator?.storage?.persist) {
return navigator.storage.persist().then(persistent => {
logger.info('persistent storage:', persistent)
}).catch(logger.error)
}
})
}) })
useEffect(() => { useEffect(() => {
@ -115,15 +133,37 @@ export const ServiceWorkerProvider = ({ children }) => {
setPermission({ notification: 'Notification' in window ? window.Notification.permission : 'denied' }) setPermission({ notification: 'Notification' in window ? window.Notification.permission : 'denied' })
if (!('serviceWorker' in navigator)) { if (!('serviceWorker' in navigator)) {
logger.info('device does not support service worker')
return return
} }
const wb = new Workbox('/sw.js', { scope: '/' }) const wb = new Workbox('/sw.js', { scope: '/' })
wb.register().then(registration => { wb.register().then(registration => {
logger.info('service worker registration successful')
setRegistration(registration) setRegistration(registration)
}) })
}, []) }, [])
useEffect(() => {
// wait until successful registration
if (!registration) return
// setup channel between app and service worker
const channel = new MessageChannel()
navigator?.serviceWorker?.controller?.postMessage({ action: ACTION_PORT }, [channel.port2])
channel.port1.onmessage = (event) => {
if (event.data.action === RESUBSCRIBE && permission.notification === 'granted') {
return subscribeToPushNotifications()
}
}
// since (a lot of) browsers don't support the pushsubscriptionchange event,
// we sync with server manually by checking on every page reload if the push subscription changed.
// see https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
navigator?.serviceWorker?.controller?.postMessage?.({ action: STORE_OS, os: detectOS() })
logger.info('sent STORE_OS to service worker: ', detectOS())
navigator?.serviceWorker?.controller?.postMessage?.({ action: SYNC_SUBSCRIPTION })
logger.info('sent SYNC_SUBSCRIPTION to service worker')
}, [registration, permission.notification])
const contextValue = useMemo(() => ({ const contextValue = useMemo(() => ({
registration, registration,
support, support,
@ -139,10 +179,6 @@ export const ServiceWorkerProvider = ({ children }) => {
) )
} }
export function clearNotifications () {
return navigator.serviceWorker?.controller?.postMessage({ action: CLEAR_NOTIFICATIONS })
}
export function useServiceWorker () { export function useServiceWorker () {
return useContext(ServiceWorkerContext) return useContext(ServiceWorkerContext)
} }

View File

@ -11,17 +11,22 @@ export const SnowProvider = ({ children }) => {
const [flakes, setFlakes] = useState(Array(1024)) const [flakes, setFlakes] = useState(Array(1024))
const snow = useCallback(() => { const snow = useCallback(() => {
// amount of flakes to add const should = window.localStorage.getItem('lnAnimate') || 'yes'
const n = Math.floor(randInRange(5, 30)) if (should === 'yes') {
const newFlakes = [...flakes] // amount of flakes to add
let i const n = Math.floor(randInRange(5, 30))
for (i = startIndex; i < (startIndex + n); ++i) { const newFlakes = [...flakes]
const key = startIndex + i let i
newFlakes[i % MAX_FLAKES] = <Snow key={key} /> 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
} }
setStartIndex(i % MAX_FLAKES) return false
setFlakes(newFlakes) }, [setFlakes, startIndex])
}, [flakes, startIndex])
return ( return (
<SnowContext.Provider value={snow}> <SnowContext.Provider value={snow}>

View File

@ -20,25 +20,6 @@ export default function SubscribeDropdownItem ({ item: { id, meSubscription } })
}, },
optimistic: true optimistic: true
}) })
const unsubscribed = !subscribeItem.meSubscription
if (!unsubscribed) return
const cacheState = cache.extract()
Object.keys(cacheState)
.filter(key => key.startsWith('Item:'))
.forEach(key => {
cache.modify({
id: key,
fields: {
meSubscription: (existing, { readField }) => {
const path = readField('path')
return !path || !path.includes(id) ? existing : false
}
},
optimistic: true
})
})
} }
} }
) )

View File

@ -1,4 +1,3 @@
import { createContext, useContext } from 'react'
import { Badge, Button, CardFooter, Dropdown } from 'react-bootstrap' import { Badge, Button, CardFooter, Dropdown } from 'react-bootstrap'
import { AccordianCard } from './accordian-item' import { AccordianCard } from './accordian-item'
import TerritoryPaymentDue, { TerritoryBillingLine } from './territory-payment-due' import TerritoryPaymentDue, { TerritoryBillingLine } from './territory-payment-due'
@ -14,16 +13,6 @@ import { useToast } from './toast'
import ActionDropdown from './action-dropdown' import ActionDropdown from './action-dropdown'
import { TerritoryTransferDropdownItem } from './territory-transfer' import { TerritoryTransferDropdownItem } from './territory-transfer'
const SubscribeTerritoryContext = createContext({ refetchQueries: [] })
export const SubscribeTerritoryContextProvider = ({ children, value }) => (
<SubscribeTerritoryContext.Provider value={value}>
{children}
</SubscribeTerritoryContext.Provider>
)
export const useSubscribeTerritoryContext = () => useContext(SubscribeTerritoryContext)
export function TerritoryDetails ({ sub, children }) { export function TerritoryDetails ({ sub, children }) {
return ( return (
<AccordianCard <AccordianCard
@ -53,10 +42,9 @@ export function TerritoryInfoSkeleton ({ children, className }) {
) )
} }
export function TerritoryInfo ({ sub, includeLink }) { export function TerritoryInfo ({ sub }) {
return ( return (
<> <>
{includeLink && <Link href={`/~${sub.name}`}>{sub.name}</Link>}
<div className='py-2'> <div className='py-2'>
<Text>{sub.desc}</Text> <Text>{sub.desc}</Text>
</div> </div>
@ -160,15 +148,12 @@ export default function TerritoryHeader ({ sub }) {
export function MuteSubDropdownItem ({ item, sub }) { export function MuteSubDropdownItem ({ item, sub }) {
const toaster = useToast() const toaster = useToast()
const { refetchQueries } = useSubscribeTerritoryContext()
const [toggleMuteSub] = useMutation( const [toggleMuteSub] = useMutation(
gql` gql`
mutation toggleMuteSub($name: String!) { mutation toggleMuteSub($name: String!) {
toggleMuteSub(name: $name) toggleMuteSub(name: $name)
}`, { }`, {
refetchQueries,
awaitRefetchQueries: true,
update (cache, { data: { toggleMuteSub } }) { update (cache, { data: { toggleMuteSub } }) {
cache.modify({ cache.modify({
id: `Sub:{"name":"${sub.name}"}`, id: `Sub:{"name":"${sub.name}"}`,
@ -227,14 +212,11 @@ export function PinSubDropdownItem ({ item: { id, position } }) {
export function ToggleSubSubscriptionDropdownItem ({ sub: { name, meSubscription } }) { export function ToggleSubSubscriptionDropdownItem ({ sub: { name, meSubscription } }) {
const toaster = useToast() const toaster = useToast()
const { refetchQueries } = useSubscribeTerritoryContext()
const [toggleSubSubscription] = useMutation( const [toggleSubSubscription] = useMutation(
gql` gql`
mutation toggleSubSubscription($name: String!) { mutation toggleSubSubscription($name: String!) {
toggleSubSubscription(name: $name) toggleSubSubscription(name: $name)
}`, { }`, {
refetchQueries,
awaitRefetchQueries: true,
update (cache, { data: { toggleSubSubscription } }) { update (cache, { data: { toggleSubSubscription } }) {
cache.modify({ cache.modify({
id: `Sub:{"name":"${name}"}`, id: `Sub:{"name":"${name}"}`,

View File

@ -5,10 +5,8 @@ import React, { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import MoreFooter from './more-footer' import MoreFooter from './more-footer'
import { useData } from './use-data' import { useData } from './use-data'
import { useMe } from './me'
import Info from './info' import Info from './info'
import ActionDropdown from './action-dropdown' import { TerritoryInfo } from './territory-header'
import { TerritoryInfo, ToggleSubSubscriptionDropdownItem, MuteSubDropdownItem } from './territory-header'
// all of this nonsense is to show the stat we are sorting by first // all of this nonsense is to show the stat we are sorting by first
const Revenue = ({ sub }) => (sub.optional.revenue !== null && <span>{abbrNum(sub.optional.revenue)} revenue</span>) const Revenue = ({ sub }) => (sub.optional.revenue !== null && <span>{abbrNum(sub.optional.revenue)} revenue</span>)
@ -37,17 +35,16 @@ function separate (arr, separator) {
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, separator] : [x]) return arr.flatMap((x, i) => i < arr.length - 1 ? [x, separator] : [x])
} }
export default function TerritoryList ({ ssrData, query, variables, destructureData, rank, subActionDropdown, statCompsProp = STAT_COMPONENTS }) { export default function TerritoryList ({ ssrData, query, variables, destructureData, rank }) {
const { data, fetchMore } = useQuery(query, { variables }) const { data, fetchMore } = useQuery(query, { variables })
const dat = useData(data, ssrData) const dat = useData(data, ssrData)
const { me } = useMe() const [statComps, setStatComps] = useState(separate(STAT_COMPONENTS, Separator))
const [statComps, setStatComps] = useState(separate(statCompsProp, Separator))
useEffect(() => { useEffect(() => {
// shift the stat we are sorting by to the front // shift the stat we are sorting by to the front
const comps = [...statCompsProp] const comps = [...STAT_COMPONENTS]
setStatComps(separate([...comps.splice(STAT_POS[variables?.by || 0], 1), ...comps], Separator)) setStatComps(separate([...comps.splice(STAT_POS[variables?.by || 0], 1), ...comps], Separator))
}, [variables?.by], statCompsProp) }, [variables?.by])
const { subs, cursor } = useMemo(() => { const { subs, cursor } = useMemo(() => {
if (!dat) return {} if (!dat) return {}
@ -80,12 +77,6 @@ export default function TerritoryList ({ ssrData, query, variables, destructureD
{sub.name} {sub.name}
</Link> </Link>
<Info className='d-flex'><TerritoryInfo sub={sub} /></Info> <Info className='d-flex'><TerritoryInfo sub={sub} /></Info>
{me && subActionDropdown && (
<ActionDropdown>
<ToggleSubSubscriptionDropdownItem sub={sub} />
<MuteSubDropdownItem sub={sub} />
</ActionDropdown>
)}
</div> </div>
<div className={styles.other}> <div className={styles.other}>
{statComps.map((Comp, i) => <Comp key={i} sub={sub} />)} {statComps.map((Comp, i) => <Comp key={i} sub={sub} />)}

View File

@ -133,12 +133,12 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child
if (outlawed) { if (outlawed) {
return href return href
} }
const isHashLink = href.startsWith('#')
// eslint-disable-next-line // eslint-disable-next-line
return <Link id={props.id} target={isHashLink ? undefined : '_blank'} rel={rel} href={href}>{children}</Link> return <Link id={props.id} target='_blank' rel={rel} href={href}>{children}</Link>
}, },
img: TextMediaOrLink, img: TextMediaOrLink,
embed: (props) => <Embed {...props} topLevel={topLevel} /> embed: Embed
}), [outlawed, rel, TextMediaOrLink, topLevel]) }), [outlawed, rel, TextMediaOrLink, topLevel])
const carousel = useCarousel() const carousel = useCarousel()

View File

@ -252,18 +252,11 @@
margin-top: .25rem; margin-top: .25rem;
} }
.text li > :is(.twitterContainer, .nostrContainer, .wavlakeWrapper, .spotifyWrapper, .onlyImages) {
display: inline-flex;
vertical-align: top;
width: 100%;
}
.text ul, .text ul,
.text ol { .text ol {
margin-top: 0; margin-top: 0;
margin-bottom: 0rem; margin-bottom: 0rem;
padding-left: 2rem; padding-left: 2rem;
max-width: calc(100% - 1rem);
} }
.text ol ol, .text ol ol,

View File

@ -1,33 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import * as cookie from 'cookie'
import { cookieOptions } from '@/lib/auth'
export default function useCookie (name) {
const [value, setValue] = useState(null)
useEffect(() => {
const checkCookie = () => {
const oldValue = value
const newValue = cookie.parse(document.cookie)[name]
if (oldValue !== newValue) setValue(newValue)
}
checkCookie()
// there's no way to listen for cookie changes that is supported by all browsers
// so we poll to detect changes
// see https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API
const interval = setInterval(checkCookie, 1000)
return () => clearInterval(interval)
}, [value])
const set = useCallback((value, options = {}) => {
document.cookie = cookie.serialize(name, value, { ...cookieOptions(), ...options })
setValue(value)
}, [name])
const remove = useCallback(() => {
document.cookie = value.serialize(name, '', { expires: 0, maxAge: 0 })
setValue(null)
}, [name])
return [value, set, remove]
}

View File

@ -17,7 +17,7 @@ function itemToContent (item, { includeTitle = true } = {}) {
content += `\n\n${item.text}` content += `\n\n${item.text}`
} }
content += `\n\nhttps://stacker.news/items/${item.id}` content += `\n\noriginally posted at https://stacker.news/items/${item.id}`
return content.trim() return content.trim()
} }

View File

@ -1,8 +1,8 @@
import { HAS_NOTIFICATIONS } from '@/fragments/notifications' import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import { clearNotifications } from '@/lib/badge'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants' import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import React, { useContext } from 'react' import React, { useContext } from 'react'
import { clearNotifications } from '@/components/serviceworker'
export const HasNewNotesContext = React.createContext(false) export const HasNewNotesContext = React.createContext(false)

View File

@ -1,180 +1,300 @@
import { useMe } from '@/components/me' import { useState, useEffect, useCallback, useRef } from 'react'
import { useCallback, useMemo } from 'react'
const VERSION = 2 export function getDbName (userId, name) {
return `app:storage:${userId ?? ''}${name ? `:${name}` : ''}`
export function useIndexedDB (dbName) {
const { me } = useMe()
if (!dbName) dbName = me?.id ? `app:storage:${me.id}` : 'app:storage'
const set = useCallback(async (storeName, key, value) => {
const db = await _open(dbName, VERSION)
try {
return await _set(db, storeName, key, value)
} finally {
db.close()
}
}, [dbName])
const get = useCallback(async (storeName, key) => {
const db = await _open(dbName, VERSION)
try {
return await _get(db, storeName, key)
} finally {
db.close()
}
}, [dbName])
const deleteDb = useCallback(async () => {
return await _delete(dbName)
}, [dbName])
const open = useCallback(async () => {
return await _open(dbName, VERSION)
}, [dbName])
return useMemo(() => ({ set, get, deleteDb, open }), [set, get, deleteDb, open])
} }
async function _open (dbName, version = 1) { const DEFAULT_OPTIONS = { keyPath: 'id', autoIncrement: true }
return await new Promise((resolve, reject) => { const DEFAULT_INDICES = []
if (typeof window.indexedDB === 'undefined') { const DEFAULT_VERSION = 1
return reject(new IndexedDBOpenError('IndexedDB unavailable'))
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
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)
}
}, [dbName, storeName, handleError, operationQueue])
useEffect(() => {
let isMounted = true
let request
try {
if (!window.indexedDB) {
console.log('IndexedDB is not supported')
setNotSupported(true)
return
}
request = window.indexedDB.open(dbName, version)
request.onerror = (event) => {
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))
}
}
} catch (error) {
handleError(new Error('Error opening database: ' + error.message))
} }
const request = window.indexedDB.open(dbName, version) return () => {
isMounted = false
request.onupgradeneeded = (event) => { if (db) {
try { db.close()
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}`))
} }
} }
}, [dbName, storeName, version, indices, options, handleError, processQueue])
request.onerror = (event) => { const queueOperation = useCallback((operation) => {
reject(new IndexedDBOpenError(request.error?.message)) if (notSupported) {
return Promise.reject(new Error('IndexedDB is not supported'))
}
if (error) {
return Promise.reject(new Error('Database error: ' + error.message))
} }
request.onsuccess = (event) => { return new Promise((resolve, reject) => {
const db = request.result const wrappedOperation = (db) => {
resolve(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 }
} }
async function _set (db, storeName, key, value) { export default useIndexedDB
return await new Promise((resolve, reject) => {
let request
try {
request = db
.transaction(storeName, 'readwrite')
.objectStore(storeName)
.put(value, key)
} catch (error) {
return reject(new IndexedDBSetError(error?.message))
}
request.onerror = (event) => {
reject(new IndexedDBSetError(event.target?.error?.message))
}
request.onsuccess = () => {
resolve(request.result)
}
})
}
async function _get (db, storeName, key) {
return await new Promise((resolve, reject) => {
let request
try {
request = db
.transaction(storeName)
.objectStore(storeName)
.get(key)
} catch (error) {
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'
}
}

View File

@ -1,8 +1,8 @@
import { useApolloClient, useMutation } from '@apollo/client' import { useApolloClient, useMutation } from '@apollo/client'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/client/errors' import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors'
import { RETRY_PAID_ACTION } from '@/fragments/paidAction' import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
import { INVOICE, CANCEL_INVOICE } from '@/fragments/invoice' import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet'
export default function useInvoice () { export default function useInvoice () {
const client = useApolloClient() const client = useApolloClient()

View File

@ -8,7 +8,6 @@ import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
import { useMe } from './me' import { useMe } from './me'
import { useWalletRecvPrompt, WalletPromptClosed } from '@/wallets/client/hooks'
// this is intented to be compatible with upsert item mutations // this is intented to be compatible with upsert item mutations
// so that it can be reused for all post types and comments and we don't have // so that it can be reused for all post types and comments and we don't have
@ -23,17 +22,9 @@ export default function useItemSubmit (mutation,
const crossposter = useCrossposter() const crossposter = useCrossposter()
const [upsertItem] = usePaidMutation(mutation) const [upsertItem] = usePaidMutation(mutation)
const { me } = useMe() const { me } = useMe()
const walletPrompt = useWalletRecvPrompt()
return useCallback( return useCallback(
async ({ boost, crosspost, title, options, bounty, status, ...values }, { resetForm }) => { async ({ boost, crosspost, title, options, bounty, status, ...values }, { resetForm }) => {
try {
await walletPrompt()
} catch (err) {
if (err instanceof WalletPromptClosed) return
throw err
}
if (options) { if (options) {
// remove existing poll options since else they will be appended as duplicates // remove existing poll options since else they will be appended as duplicates
options = options.slice(item?.poll?.options?.length || 0).filter(o => o.trim().length > 0) options = options.slice(item?.poll?.options?.length || 0).filter(o => o.trim().length > 0)
@ -102,7 +93,7 @@ export default function useItemSubmit (mutation,
} }
} }
}, [me, upsertItem, router, crossposter, item, sub, onSuccessfulSubmit, }, [me, upsertItem, router, crossposter, item, sub, onSuccessfulSubmit,
navigateOnSubmit, extraValues, paidMutationOptions, walletPrompt] navigateOnSubmit, extraValues, paidMutationOptions]
) )
} }

View File

@ -1,124 +0,0 @@
import preserveScroll from './preserve-scroll'
import { GET_NEW_COMMENTS } from '../fragments/comments'
import { useEffect, useState } from 'react'
import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { useQuery, useApolloClient } from '@apollo/client'
import { commentsViewedAfterComment } from '../lib/new-comments'
import {
updateItemQuery,
updateCommentFragment,
getLatestCommentCreatedAt,
updateAncestorsCommentCount,
calculateDepth
} from '../lib/comments'
const POLL_INTERVAL = 1000 * 5 // 5 seconds
// prepares and creates a fragment for injection into the cache
// also handles side effects like updating comment counts and viewedAt timestamps
function prepareComments (item, cache, newComment) {
const existingComments = item.comments?.comments || []
// is the incoming new comment already in item's existing comments?
// if so, we don't need to update the cache
if (existingComments.some(comment => comment.id === newComment.id)) return item
// count the new comment (+1) and its children (+ncomments)
const totalNComments = newComment.ncomments + 1
const itemHierarchy = item.path.split('.')
// update all ancestors comment count, but not the item itself
const ancestors = itemHierarchy.slice(0, -1)
updateAncestorsCommentCount(cache, ancestors, totalNComments)
// update commentsViewedAt to now, and add the number of new comments
const rootId = itemHierarchy[0]
commentsViewedAfterComment(rootId, Date.now(), totalNComments)
// add a flag to the new comment to indicate it was injected
const injectedComment = { ...newComment, injected: true }
// an item can either have a comments.comments field, or not
const payload = item.comments
? {
...item,
ncomments: item.ncomments + totalNComments,
comments: {
...item.comments,
comments: [injectedComment, ...item.comments.comments]
}
}
// when the fragment doesn't have a comments field, we just update stats fields
: {
...item,
ncomments: item.ncomments + totalNComments
}
return payload
}
function cacheNewComments (cache, rootId, newComments, sort) {
for (const newComment of newComments) {
const { parentId } = newComment
const topLevel = Number(parentId) === Number(rootId)
// if the comment is a top level comment, update the item, else update the parent comment
if (topLevel) {
updateItemQuery(cache, rootId, sort, (item) => prepareComments(item, cache, newComment))
} else {
// if the comment is too deep, we can skip it
const depth = calculateDepth(newComment.path, rootId, parentId)
if (depth > COMMENT_DEPTH_LIMIT) continue
// inject the new comment into the parent comment's comments field
updateCommentFragment(cache, parentId, (parent) => prepareComments(parent, cache, newComment))
}
}
}
// useLiveComments fetches new comments under an item (rootId),
// that are newer than the latest comment createdAt (after), and injects them into the cache.
export default function useLiveComments (rootId, after, sort) {
const latestKey = `liveCommentsLatest:${rootId}`
const { cache } = useApolloClient()
const [latest, setLatest] = useState(after)
const [initialized, setInitialized] = useState(false)
useEffect(() => {
if (typeof window !== 'undefined') {
const storedLatest = window.sessionStorage.getItem(latestKey)
if (storedLatest && storedLatest > after) {
setLatest(storedLatest)
} else {
setLatest(after)
}
}
// Apollo might update the cache before the page has fully rendered, causing reads of stale cached data
// this prevents GET_NEW_COMMENTS from producing results before the page has fully rendered
setInitialized(true)
}, [after])
const { data } = useQuery(GET_NEW_COMMENTS, SSR || !initialized
? {}
: {
pollInterval: POLL_INTERVAL,
// only get comments newer than the passed latest timestamp
variables: { rootId, after: latest },
nextFetchPolicy: 'cache-and-network'
})
useEffect(() => {
if (!data?.newComments?.comments?.length) return
// directly inject new comments into the cache, preserving scroll position
// quirk: scroll is preserved even if we are not injecting new comments due to dedupe
preserveScroll(() => cacheNewComments(cache, rootId, data.newComments.comments, sort))
// update latest timestamp to the latest comment created at
// save it to session storage, to persist between client-side navigations
const newLatest = getLatestCommentCreatedAt(data.newComments.comments, latest)
setLatest(newLatest)
if (typeof window !== 'undefined') {
window.sessionStorage.setItem(latestKey, newLatest)
}
}, [data, cache, rootId, sort, latest])
}

View File

@ -2,9 +2,9 @@ import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import useQrPayment from '@/components/use-qr-payment' import useQrPayment from '@/components/use-qr-payment'
import useInvoice from '@/components/use-invoice' import useInvoice from '@/components/use-invoice'
import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/client/errors' import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/errors'
import { GET_PAID_ACTION } from '@/fragments/paidAction' import { GET_PAID_ACTION } from '@/fragments/paidAction'
import { useWalletPayment } from '@/wallets/client/hooks' import { useWalletPayment } from '@/wallets/payment'
/* /*
this is just like useMutation with a few changes: this is just like useMutation with a few changes:

View File

@ -1,9 +1,9 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import Invoice from '@/components/invoice' import Invoice from '@/components/invoice'
import { AnonWalletError, InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/client/errors' import { InvoiceCanceledError, InvoiceExpiredError, AnonWalletError } from '@/wallets/errors'
import { useShowModal } from '@/components/modal' import { useShowModal } from '@/components/modal'
import useInvoice from '@/components/use-invoice' import useInvoice from '@/components/use-invoice'
import { sendPayment as weblnSendPayment } from '@/wallets/client/protocols/webln' import { sendPayment } from '@/wallets/webln/client'
export default function useQrPayment () { export default function useQrPayment () {
const invoice = useInvoice() const invoice = useInvoice()
@ -19,7 +19,7 @@ export default function useQrPayment () {
) => { ) => {
// if anon user and webln is available, try to pay with webln // if anon user and webln is available, try to pay with webln
if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) { if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) {
weblnSendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) }) sendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) })
} }
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
let paid let paid

View File

@ -1,32 +0,0 @@
import { useEffect, useState } from 'react'
// observe the passed element ref and return its visibility
export default function useVisibility (elementRef, options = {}) {
// threshold is the percentage of the element that must be visible to be considered visible
// with pastElement, we consider the element not visible only when we're past it
const { threshold = 0, pastElement = false } = options
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
const element = elementRef.current
if (!element || !window.IntersectionObserver || typeof window === 'undefined') return
const observer = new window.IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
} else if (pastElement) {
setIsVisible(entry.boundingClientRect.top > 0)
} else {
setIsVisible(false)
}
}, { threshold }
)
// observe the passed element ref
observer.observe(element)
return () => observer.disconnect()
}, [threshold, elementRef, pastElement])
return isVisible
}

View File

@ -8,7 +8,6 @@ import { useState, useEffect } from 'react'
import { Form, Input, SubmitButton } from './form' import { Form, Input, SubmitButton } from './form'
import { gql, useApolloClient, useMutation } from '@apollo/client' import { gql, useApolloClient, useMutation } from '@apollo/client'
import styles from './user-header.module.css' import styles from './user-header.module.css'
import navStyles from '@/styles/nav.module.css'
import { useMe } from './me' import { useMe } from './me'
import { NAME_MUTATION } from '@/fragments/users' import { NAME_MUTATION } from '@/fragments/users'
import { QRCodeSVG } from 'qrcode.react' import { QRCodeSVG } from 'qrcode.react'
@ -29,11 +28,9 @@ import { hexToBech32 } from '@/lib/nostr'
import NostrIcon from '@/svgs/nostr.svg' import NostrIcon from '@/svgs/nostr.svg'
import GithubIcon from '@/svgs/github-fill.svg' import GithubIcon from '@/svgs/github-fill.svg'
import TwitterIcon from '@/svgs/twitter-fill.svg' import TwitterIcon from '@/svgs/twitter-fill.svg'
import { UNKNOWN_LINK_REL } from '@/lib/constants' import { UNKNOWN_LINK_REL, MEDIA_URL } from '@/lib/constants'
import ItemPopover from './item-popover' import ItemPopover from './item-popover'
const MEDIA_URL = process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}`
export default function UserHeader ({ user }) { export default function UserHeader ({ user }) {
const router = useRouter() const router = useRouter()
@ -45,7 +42,7 @@ export default function UserHeader ({ user }) {
<> <>
<HeaderHeader user={user} /> <HeaderHeader user={user} />
<Nav <Nav
className={navStyles.nav} className={styles.nav}
activeKey={activeKey} activeKey={activeKey}
> >
<Nav.Item> <Nav.Item>

View File

@ -3,6 +3,24 @@
margin-bottom: 0; margin-bottom: 0;
} }
.nav {
margin: 1rem 0;
justify-content: start;
font-size: 110%;
}
.nav :global .nav-link {
padding-left: 0;
}
.nav :global .nav-item:not(:first-child) {
margin-left: 1rem;
}
.nav :global .active {
border-bottom: 2px solid var(--bs-primary);
}
.userimg { .userimg {
clip-path: polygon(0% 0%, 83% 0%, 100% 100%, 17% 100%); clip-path: polygon(0% 0%, 83% 0%, 100% 100%, 17% 100%);
object-fit: cover; object-fit: cover;

Some files were not shown because too many files have changed in this diff Show More