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
LNWITH_URL=http://localhost:3000/api/lnwith
# auto social poster
TWITTER_POSTER_API_KEY=
TWITTER_POSTER_API_KEY_SECRET=
TWITTER_POSTER_ACCESS_TOKEN=
TWITTER_POSTER_ACCESS_TOKEN_SECRET=
########################################
# SNDEV STUFF WE PRESET #
# which you can override in .env.local #
@ -63,8 +57,8 @@ INVOICE_HMAC_KEY=a4c1d9c81edb87b79d28809876a18cf72293eadb39f92f3f4f2f1cfbdf907c9
# lnd
# xxd -p -c0 docker/lnd/sn/regtest/admin.macaroon
LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a4343416569674177494241674951484f4a69597458736c72592f4931376933574c444354414b42676771686b6a4f50515144416a41344d5238770a485159445651514b45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d52557745775944565151444577777a4e54526d4d574e690a4f546b7a595451774868634e4d6a55774e54497a4d4467784d444d345768634e4d6a59774e7a45344d4467784d444d34576a41344d523877485159445651514b0a45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d52557745775944565151444577777a4e54526d4d574e694f546b7a595451770a5754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e434141524b6d733131422b4e58554e642f54574347492b4b2b5046686b485a31410a5449647732566e766a344f6130784c696c515a4d7779647149586c7a724641485064646a3566697934584c456f43364d4e427636585277706f3448544d4948510a4d41344741315564447745422f775145417749437044415442674e56485355454444414b4267677242674546425163444154415042674e5648524d42416638450a425441444151482f4d42304741315564446751574242526f433554634e58746366464f7458393171364364337a6930327a54423542674e5648524545636a42770a6767777a4e54526d4d574e694f546b7a5954534343577876593246736147397a64494947633235666247356b6768526f62334e304c6d52765932746c636935700a626e526c636d356862494945645735706549494b64573570654842685932746c64494948596e566d59323975626f6345667741414159635141414141414141410a4141414141414141414141414159634572424941427a414b42676771686b6a4f5051514441674e494144424641694541324941462b32436746704a754e5445750a34524f63322f70625870476f4934365573724a65525972614d33414349423974424c6759777a597a2b596b5a4e7a417a7077454c754935564f505959724a6f6b0a7270754d32316b690a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a
LND_MACAROON=0201036c6e6402f801030a10ba643b9c3fe23f760e1ee63e0196656e1201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620fd0027075985f7073217aa9aaae4d14db0e7ca38f4e572c3b85c81cf6bb580b3
LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a43434165696741774942416749516139493834682b48653350385a437541525854554d54414b42676771686b6a4f50515144416a41344d5238770a485159445651514b45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d5255774577594456515144457778694e6a41785a5749780a4d474d354f444d774868634e4d6a51774d7a41334d5463774d6a45355768634e4d6a55774e5441794d5463774d6a4535576a41344d523877485159445651514b0a45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d5255774577594456515144457778694e6a41785a5749784d474d354f444d770a5754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e4341415365596a4b62542b4a4a4a37624b6770677a6d6c3278496130364e3174680a2f4f7033533173382b4f4a41387836647849682f326548556b4f7578675a36703549434b496f375a544c356a5963764375793941334b6e466f3448544d4948510a4d41344741315564447745422f775145417749437044415442674e56485355454444414b4267677242674546425163444154415042674e5648524d42416638450a425441444151482f4d4230474131556444675157424252545756796e653752786f747568717354727969466d6a36736c557a423542674e5648524545636a42770a676778694e6a41785a5749784d474d354f444f4343577876593246736147397a64494947633235666247356b6768526f62334e304c6d52765932746c636935700a626e526c636d356862494945645735706549494b64573570654842685932746c64494948596e566d59323975626f6345667741414159635141414141414141410a41414141414141414141414141596345724273414254414b42676771686b6a4f5051514441674e4941444246416945413873616c4a667134476671465557532f0a35347a335461746c6447736673796a4a383035425a5263334f326f434943794e6e3975716976566f5575365935345143624c3966394c575779547a516e61616e0a656977482f51696b0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a
LND_MACAROON=0201036c6e6402f801030a106cf4e146abffa5d766befbbf4c73b5a31201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e6572617465120472656164000006202c3bfd55c191e925cbffd73712c9d4b9b4a8440410bde5f8a0a6e33af8b3d876
LND_SOCKET=sn_lnd:10009
# nostr (NIP-57 zap receipts)
@ -85,7 +79,6 @@ IMGPROXY_READ_TIMEOUT=10
IMGPROXY_WRITE_TIMEOUT=10
IMGPROXY_DOWNLOAD_TIMEOUT=9
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
IMGPROXY_ALLOW_ORIGIN=http://localhost:3000
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
# IMGPROXY_ENABLE_DEBUG_HEADERS=true
@ -140,8 +133,8 @@ SN_LND_REST_PORT=8080
SN_LND_GRPC_PORT=10009
SN_LND_P2P_PORT=9735
# docker exec -u lnd sn_lnd lncli newaddress p2wkh --unused
SN_LND_PUBKEY=03dc0de8fbe29ef3d26554c615adfd17aaca959403c4e9ecebaac4b83978d86342
SN_LND_ADDR=bcrt1qu6g49vrl8n4ay99hr04wefkfy2e8g0z4nc0sjw
SN_LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl
SN_LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
# sn_lndk stuff
SN_LNDK_GRPC_PORT=10012
@ -184,16 +177,10 @@ grpc_proxy=http://tor:7050/
# lnbits
LNBITS_WEB_PORT=5001
LNBITS_WEB_PORT_V1=5002
# CPU shares for each category
CPU_SHARES_IMPORTANT=1024
CPU_SHARES_MODERATE=512
CPU_SHARES_LOW=256
NEXT_TELEMETRY_DISABLED=1
# 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
NEXT_TELEMETRY_DISABLED=1

View File

@ -10,9 +10,8 @@ _Was anything unclear during your work on this PR? Anything we should definitely
## 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:**

View File

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

11
.gitignore vendored
View File

@ -53,7 +53,6 @@ docker-compose.*.yml
*.sql
!/prisma/migrations/*/*.sql
!/docker/db/seed.sql
!/docker/db/wallet-seed.sql
# nostr wallet connect
scripts/nwc-keys.json
@ -66,12 +65,4 @@ docker/lnbits/data
# nostr link extract
scripts/nostr-link-extract.config.json
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/*
scripts/nostr-links.db

View File

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

View File

@ -103,7 +103,7 @@ stateDiagram-v2
| donations | x | | x | x | x | | | x | |
| update posts | x | | x | | x | | x | x | |
| update comments | x | | x | | x | | x | x | |
| receive | | | | | x | x | x | | x |
| receive | | x | | | x | x | x | | x |
| buy fee credits | | | x | | x | | | x | |
| invite gift | x | | | | | | x | x | |
@ -205,7 +205,7 @@ The ONLY exception to this are for the `users` table where we store a stacker's
We use a `read committed` isolation level for actions. This means paid actions need to be mindful of concurrency issues. Specifically, reading data from the database and then writing it back in `read committed` is a common source of consistency bugs (aka serialization anamolies).
### This is a big deal
1. If you read from the database and intend to use that data to write to the database, and it's possible that a concurrent transaction could change the data you've read (it usually is), you need to be prepared to handle that (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:
- independent statements
- `WITH` queries (CTEs) in the same statement

View File

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

View File

@ -227,7 +227,7 @@ async function performP2PAction (actionType, args, incomingContext) {
await assertBelowMaxPendingInvoices(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,
feePercent: sybilFeePercent,
description,
@ -239,7 +239,7 @@ async function performP2PAction (actionType, args, incomingContext) {
invoiceArgs: {
bolt11: invoice,
wrappedBolt11: wrappedInvoice,
protocol,
wallet,
maxFee
}
}
@ -269,7 +269,7 @@ async function performDirectAction (actionType, args, incomingContext) {
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
for await (const { invoice, logger, protocol } of createUserInvoice(userId, {
for await (const { invoice, logger, wallet } of createUserInvoice(userId, {
msats: cost,
description,
expiry: INVOICE_EXPIRE_SECS
@ -293,7 +293,7 @@ async function performDirectAction (actionType, args, incomingContext) {
bolt11: invoice,
msats: cost,
hash,
protocolId: protocol.id,
walletId: wallet.id,
receiverId: userId
}
}),
@ -346,26 +346,22 @@ export async function retryPaidAction (actionType, args, incomingContext) {
invoiceId: failedInvoice.id
},
include: {
protocol: {
include: {
wallet: true
}
}
wallet: true
}
})
if (invoiceForward) {
// this is a wrapped invoice, we need to retry it with receiver fallbacks
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
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, protocol, maxFee } = await createWrappedInvoice(userId, {
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: failedInvoice.msatsRequested,
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
description: await action.describe?.(actionArgs, retryContext),
expiry: INVOICE_EXPIRE_SECS
}, retryContext)
invoiceArgs = { bolt11, wrappedBolt11, protocol, maxFee }
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
} catch (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) {
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
@ -472,9 +468,9 @@ async function createDbInvoice (actionType, args, context) {
invoice: {
create: invoiceData
},
protocol: {
wallet: {
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 { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { GqlInputError } from '@/lib/error'
import { throwOnExpiredUploads } from '@/api/resolvers/upload'
export const anonable = true
@ -39,12 +38,12 @@ export async function getBaseCost ({ models, bio, parentId, subName }) {
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
const baseCost = await getBaseCost({ models, bio, parentId, subName })
// cost = baseCost * 10^num_items_in_10m * 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`
SELECT ${baseCost}::INTEGER
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? USER_ID.anon}::INTEGER,
${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
* ${me ? 1 : ANON_FEE_MULTIPLIER}::INTEGER
* ${me ? 1 : 100}::INTEGER
+ (SELECT "nUnpaid" * "uploadFeesMsats"
FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ ${satsToMsats(boost)}::INTEGER as cost`
@ -62,7 +61,15 @@ export async function perform (args, context) {
const { tx, me, cost } = context
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 = {}
if (invoiceId) {

View File

@ -1,5 +1,5 @@
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 { notifyItemMention, notifyMention } from '@/lib/webPush'
import { satsToMsats } from '@/lib/format'
@ -17,7 +17,7 @@ export async function getCost ({ id, boost = 0, uploadIds, bio }, { me, models }
// or more boost
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
const cost = totalFeesMsats + satsToMsats(boost - old.boost)
const cost = BigInt(totalFeesMsats) + satsToMsats(boost - old.boost)
if (cost > 0 && old.invoiceActionState && old.invoiceActionState !== '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 itemUploads = uploadIds.map(id => ({ uploadId: id }))
await throwOnExpiredUploads(uploadIds, { tx })
await tx.upload.updateMany({
where: { id: { in: uploadIds } },
data: { paid: true }
@ -164,8 +163,7 @@ export async function nonCriticalSideEffects ({ invoice, id }, { models }) {
where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } },
user: true
itemReferrers: { include: { refereeItem: true } }
}
})
// 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 { toPositiveBigInt, numWithUnits, msatsToSats } from '@/lib/format'
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { toPositiveBigInt, numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
import { notifyDeposit } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server'
@ -16,20 +16,22 @@ export async function getCost ({ msats }) {
export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) {
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) 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
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && !me?.directReceive) return null
const wallets = await getInvoiceableWallets(me.id, { models })
if (wallets.length === 0) {
return null
}
if (cost < satsToMsats(me.receiveCreditsBelowSats)) {
return null
}
return me.id
}
export async function getSybilFeePercent () {
return PROXY_RECEIVE_FEE_PERCENT
return 10n
}
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))}`
}
export async function onPaid ({ invoice }, { tx }) {
if (!invoice) {
throw new Error('invoice is required')
}
// P2P lnurlp does not need to update the user's balance
if (invoice?.invoiceForward) return
await tx.user.update({
where: { id: invoice.userId },
data: {
mcredits: {
increment: invoice.msatsReceived
}
}
})
}
export async function nonCriticalSideEffects ({ invoice }, { models }) {
await notifyDeposit(invoice.userId, invoice)
await models.$executeRaw`

View File

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

View File

@ -36,15 +36,8 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
data.userId = me.id
if (sub.userId !== me.id) {
try {
// XXX this will throw if this transfer has already happened
// TODO: upsert this
await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } })
// 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.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } })
await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } })
}
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 })
for (const t of trust) {
await tx.userSubTrust.upsert({
where: {
userId_subName: { userId: t.userId, subName: t.subName }
},
update: t,
create: t
})
}
await tx.userSubTrust.createMany({
data: initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
})
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 { proratedBillingCost } from '@/lib/territory'
import { datePivot } from '@/lib/time'
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
export const anonable = false
@ -12,16 +11,18 @@ export const paymentMethods = [
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({
where: {
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)
}
@ -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({
data,
where: {

View File

@ -39,11 +39,11 @@ export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models,
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
// and the receiver doesn't want to receive credits
if (protocols.length > 0 &&
if (wallets.length > 0 &&
item.itemForwards.length === 0 &&
sats >= item.user.receiveCreditsBelowSats) {
return item.userId
@ -151,7 +151,7 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
await tx.$queryRaw`
WITH territory AS (
SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName"
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
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
// and there's only one paying action: send
// ... still we want the api to at least be similar
export default async function performPayingAction ({ bolt11, maxFee, protocolId }, { me, models, lnd }) {
export default async function performPayingAction ({ bolt11, maxFee, walletId }, { me, models, lnd }) {
try {
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, protocolId)
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, walletId)
if (!me) {
throw new Error('You must be logged in to perform this action')
@ -34,8 +34,8 @@ export default async function performPayingAction ({ bolt11, maxFee, protocolId
msatsPaying: toPositiveBigInt(decoded.mtokens),
msatsFeePaying: satsToMsats(maxFee),
userId: me.id,
protocolId,
autoWithdraw: !!protocolId
walletId,
autoWithdraw: !!walletId
}
})
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })

View File

@ -1,8 +1,7 @@
import user from './user'
import message from './message'
import item from './item'
import walletV1 from './wallet'
import walletV2 from '@/wallets/server/resolvers'
import wallet from './wallet'
import lnurl from './lnurl'
import notifications from './notifications'
import invite from './invite'
@ -20,6 +19,7 @@ import chainFee from './chainFee'
import { GraphQLScalarType, Kind } from 'graphql'
import { createIntScalar } from 'graphql-scalar'
import paidAction from './paidAction'
import vault from './vault'
const date = new GraphQLScalarType({
name: 'Date',
@ -54,6 +54,6 @@ const limit = createIntScalar({
maximum: 1000
})
export default [user, item, message, 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,
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction]
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]

View File

@ -15,6 +15,7 @@ import {
FULL_COMMENTS_THRESHOLD
} from '@/lib/constants'
import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts'
import uu from 'url-unshort'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, validateSchema } from '@/lib/validate'
import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
@ -25,15 +26,11 @@ import assertApiKeyNotPermitted from './apiKey'
import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { verifyHmac } from './wallet'
import { parse } from 'tldts'
import { shuffleArray } from '@/lib/rand'
function commentsOrderByClause (me, models, sort) {
const sharedSortsArray = []
sharedSortsArray.push('("Item"."pinId" IS NOT NULL) DESC')
sharedSortsArray.push('("Item"."deletedAt" IS NULL) DESC')
// outlawed items should be at the bottom
sharedSortsArray.push(`NOT ("Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD} OR "Item".outlawed) DESC`)
const sharedSorts = sharedSortsArray.join(', ')
if (sort === 'recent') {
@ -512,7 +509,7 @@ export default {
${whereClause(
'"parentId" IS NULL',
'"Item"."deletedAt" IS NULL',
activeOrMine(me),
'"Item"."status" = \'ACTIVE\'',
'created_at <= $1',
'"pinId" IS NULL',
subClause(sub, 4)
@ -595,13 +592,7 @@ export default {
const response = await fetch(ensureProtocol(url), { redirect: 'follow' })
const html = await response.text()
const doc = domino.createWindow(html).document
const titleRuleSet = {
rules: [
['h1 > yt-formatted-string.ytd-watch-metadata', el => el.getAttribute('title')],
...metadataRuleSets.title.rules
]
}
const metadata = getMetadata(doc, url, { title: titleRuleSet, publicationDate: publicationDateRuleSet })
const metadata = getMetadata(doc, url, { title: metadataRuleSets.title, publicationDate: publicationDateRuleSet })
const dateHint = ` (${metadata.publicationDate?.getFullYear()})`
const moreThanOneYearAgo = metadata.publicationDate && metadata.publicationDate < datePivot(new Date(), { years: -1 })
@ -622,6 +613,7 @@ export default {
const urlObj = new URL(ensureProtocol(url))
let { hostname, pathname } = urlObj
// remove subdomain from hostname
const parseResult = parse(urlObj.hostname)
if (parseResult?.subdomain?.length > 0) {
hostname = hostname.replace(`${parseResult.subdomain}.`, '')
@ -647,9 +639,6 @@ export default {
} else if (urlObj.hostname === 'yewtu.be') {
const matches = url.match(/(https?:\/\/)?yewtu\.be.*(v=|embed\/)(?<id>[_0-9a-z-]+)/i)
similar = `^(http(s)?:\\/\\/)?yewtu\\.be\\/(watch\\?v\\=|embed\\/)${matches?.groups?.id}&?`
} else {
// only allow ending of mismatching search params
similar += '(?:\\?.*)?$'
}
return await itemQueryWithMeta({
@ -658,7 +647,7 @@ export default {
query: `
${SELECT}
FROM "Item"
WHERE url ~* $1 AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID')
WHERE url ~* $1
ORDER BY created_at DESC
LIMIT 3`
}, similar)
@ -705,11 +694,7 @@ export default {
status: 'ACTIVE',
deletedAt: null,
outlawed: false,
parentId: null,
OR: [
{ invoiceActionState: 'PAID' },
{ invoiceActionState: null }
]
parentId: null
}
if (id) {
where.id = { not: Number(id) }
@ -739,24 +724,6 @@ export default {
homeMaxBoost: homeAgg._max.boost || 0,
subMaxBoost: subAgg?._max.boost || 0
}
},
newComments: async (parent, { rootId, after }, { models, me }) => {
const comments = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
-- comments can be nested, so we need to get all comments that are descendants of the root
${whereClause(
'"Item".path <@ (SELECT path FROM "Item" WHERE id = $1 AND "Item"."lastCommentAt" > $2)',
activeOrMine(me),
'"Item"."created_at" > $2'
)}
ORDER BY "Item"."created_at" ASC`
}, Number(rootId), after)
return { comments }
}
},
@ -864,16 +831,8 @@ export default {
const data = { itemId: Number(id), userId: me.id }
const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } })
if (old) {
await models.$executeRaw`
DELETE FROM "ThreadSubscription" ts
USING "Item" i
WHERE ts."userId" = ${me.id}
AND i.path <@ (SELECT path FROM "Item" WHERE id = ${Number(id)})
AND ts."itemId" = i.id
`
} else {
await models.threadSubscription.create({ data })
}
await models.threadSubscription.delete({ where: { userId_itemId: data } })
} else await models.threadSubscription.create({ data })
return { id }
},
deleteItem: async (parent, { id }, { me, models }) => {
@ -1189,8 +1148,7 @@ export default {
poll.meVoted = false
}
poll.randPollOptions = item?.randPollOptions
poll.options = poll.randPollOptions ? shuffleArray(options) : options
poll.options = options
poll.count = options.reduce((t, o) => t + o.count, 0)
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')
}
if (item.url && !isJob({ subName, ...item })) {
if (item.url && !isJob(item)) {
item.url = ensureProtocol(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.forwardUsers = await getForwardUsers(models, forward)
}
item.uploadIds = uploadIdsFromText(item.text)
item.uploadIds = uploadIdsFromText(item.text, { models })
// never change author of item
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.forwardUsers = await getForwardUsers(models, forward)
item.uploadIds = uploadIdsFromText(item.text)
item.uploadIds = uploadIdsFromText(item.text, { models })
if (item.url && !isJob(item)) {
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 { getInvoice, getWithdrawl } from './wallet'
import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
import { sendPushSubscriptionReply } from '@/lib/webPush'
import { replyToSubscription } from '@/lib/webPush'
import { getSub } from './sub'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
@ -316,36 +316,13 @@ export default {
if (meFull.noteCowboyHat) {
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"
WHERE "userId" = $1
AND updated_at < $2
AND type = 'COWBOY_HAT'
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
for (const type of ['HORSE', 'GUN']) {
const gqlType = type.charAt(0) + type.slice(1).toLowerCase()
queries.push(
`(SELECT id::text, "startedAt" AS "sortTime", 0 as "earnedSats", 'New${gqlType}' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at < $2
AND type = '${type}'::"StreakType"
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT id::text AS id, "endedAt" AS "sortTime", 0 as "earnedSats", 'Lost${gqlType}' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at < $2
AND "endedAt" IS NOT NULL
AND type = '${type}'::"StreakType"
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}
}
queries.push(
@ -439,7 +416,7 @@ export default {
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
},
@ -484,13 +461,8 @@ export default {
},
TerritoryTransfer: {
sub: async (n, args, { models, me }) => {
const [sub] = await models.$queryRaw`
SELECT "Sub".*
FROM "TerritoryTransfer"
JOIN "Sub" ON "Sub"."name" = "TerritoryTransfer"."subName"
WHERE "TerritoryTransfer"."id" = ${Number(n.id)}`
return sub
const transfer = await models.territoryTransfer.findUnique({ where: { id: Number(n.id) }, include: { sub: true } })
return transfer.sub
}
},
JobChanged: {
@ -528,14 +500,23 @@ export default {
}
}
},
CowboyHat: {
Streak: {
days: async (n, args, { models }) => {
const res = await models.$queryRaw`
SELECT "endedAt"::date - "startedAt"::date AS days
SELECT "endedAt" - "startedAt" AS days
FROM "Streak"
WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL
`
return res.length ? res[0].days : null
},
type: async (n, args, { models }) => {
const res = await models.$queryRaw`
SELECT "type"
FROM "Streak"
WHERE id = ${Number(n.id)}
`
return res.length ? res[0].type : null
}
},
Earn: {

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,6 @@ import assertApiKeyNotPermitted from './apiKey'
import { hashEmail } from '@/lib/crypto'
import { isMuted } from '@/lib/user'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { processCrop } from '@/worker/imgproxy'
const contributors = new Set()
@ -728,18 +727,6 @@ export default {
return true
},
cropPhoto: async (parent, { photoId, cropData }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const croppedUrl = await processCrop({ photoId: Number(photoId), cropData })
if (!croppedUrl) {
throw new GqlInputError('can\'t crop photo')
}
return croppedUrl
},
setPhoto: async (parent, { photoId }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
@ -911,22 +898,6 @@ export default {
await models.user.update({ where: { id: me.id }, data: { hideWelcomeBanner: true } })
return true
},
hideWalletRecvPrompt: async (parent, data, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.user.update({ where: { id: me.id }, data: { hideWalletRecvPrompt: true } })
return true
},
setDiagnostics: async (parent, { diagnostics }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.user.update({ where: { id: me.id }, data: { diagnostics } })
return diagnostics
}
},
@ -1100,9 +1071,6 @@ export default {
return false
}
return !!user.tipRandomMin && !!user.tipRandomMax
},
hideWalletRecvPrompt: async (user, args, { models }) => {
return user.hideWalletRecvPrompt || user.hasRecvWallet
}
},
@ -1114,17 +1082,19 @@ export default {
return user.streak
},
hasSendWallet: async (user, args, { models }) => {
gunStreak: async (user, args, { models }) => {
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) {
return false
return null
}
return user.hasRecvWallet
return user.horseStreak
},
maxStreak: async (user, args, { models }) => {
if (user.hideCowboyHat) {
@ -1132,9 +1102,8 @@ export default {
}
const [{ max }] = await models.$queryRaw`
SELECT MAX(COALESCE("endedAt"::date, (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt"::date)
FROM "Streak" WHERE "userId" = ${user.id}
AND type = 'COWBOY_HAT'`
SELECT MAX(COALESCE("endedAt", (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt")
FROM "Streak" WHERE "userId" = ${user.id}`
return max
},
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 { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item'
import { msatsToSats, msatsToSatsDecimal } from '@/lib/format'
import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
import {
USER_ID, INVOICE_RETENTION_DAYS,
PAID_ACTION_PAYMENT_METHODS,
WALLET_CREATE_INVOICE_TIMEOUT_MS,
WALLET_RETRY_AFTER_MS,
WALLET_RETRY_BEFORE_MS,
WALLET_MAX_RETRIES
} from '@/lib/constants'
import { validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
import { bolt11Tags } from '@/lib/bolt11'
import { finalizeHodlInvoice } from '@/worker/wallet'
import walletDefs from '@/wallets/server'
import { generateResolverName, generateTypeDefName } from '@/wallets/graphql'
import { lnAddrOptions } from '@/lib/lnurl'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { getNodeSockets } from '../lnd'
import { getNodeSockets, getOurPubkey } from '../lnd'
import validateWallet from '@/wallets/validate'
import { canReceive, getWalletByType } from '@/wallets/common'
import performPaidAction from '../paidAction'
import performPayingAction from '../payingAction'
import { timeoutSignal, withTimeout } from '@/lib/time'
function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:')
for (const walletDef of walletDefs) {
const resolverName = generateResolverName(walletDef.walletField)
console.log(resolverName)
resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
console.log('resolving', resolverName, { settings, validateLightning, vaultEntries, ...data })
let existingVaultEntries
if (typeof vaultEntries === 'undefined' && data.id) {
// this mutation was sent from an unsynced client
// to pass validation, we need to add the existing vault entries for validation
// in case the client is removing the receiving config
existingVaultEntries = await models.vaultEntry.findMany({
where: {
walletId: Number(data.id)
}
})
}
const validData = await validateWallet(walletDef,
{ ...data, ...settings, vaultEntries: vaultEntries ?? existingVaultEntries },
{ serverSide: true })
if (validData) {
data && Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
settings && Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
}
// wallet in shape of db row
const wallet = {
field: walletDef.walletField,
type: walletDef.walletType,
userId: me?.id
}
const logger = walletLogger({ wallet, models })
return await upsertWallet({
wallet,
walletDef,
testCreateInvoice:
walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data })
? (data) => withTimeout(
walletDef.testCreateInvoice(data, {
logger,
signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS)
}),
WALLET_CREATE_INVOICE_TIMEOUT_MS)
: null
}, {
settings,
data,
vaultEntries
}, { logger, me, models })
}
}
console.groupEnd()
return resolvers
}
export async function getInvoice (parent, { id }, { me, models, lnd }) {
const inv = await models.invoice.findUnique({
@ -87,6 +154,54 @@ export function verifyHmac (hash, hmac) {
const resolvers = {
Query: {
invoice: getInvoice,
wallet: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
return await models.wallet.findUnique({
where: {
userId: me.id,
id: Number(id)
},
include: {
vaultEntries: true
}
})
},
walletByType: async (parent, { type }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const wallet = await models.wallet.findFirst({
where: {
userId: me.id,
type
},
include: {
vaultEntries: true
}
})
return wallet
},
wallets: async (parent, args, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
return await models.wallet.findMany({
include: {
vaultEntries: true
},
where: {
userId: me.id
},
orderBy: {
priority: 'asc'
}
})
},
withdrawl: getWithdrawl,
direct: async (parent, { id }, { me, models }) => {
if (!me) {
@ -292,6 +407,67 @@ const resolvers = {
facts: history
}
},
walletLogs: async (parent, { type, from, to, cursor }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
// we cursoring with the wallet logs on the client
// if we have from, don't use cursor
// regardless, store the state of the cursor for the next call
const decodedCursor = cursor ? decodeCursor(cursor) : { offset: 0, time: to ?? new Date() }
let logs = []
let nextCursor
if (from) {
logs = await models.walletLog.findMany({
where: {
userId: me.id,
wallet: type ?? undefined,
createdAt: {
gt: from ? new Date(Number(from)) : undefined,
lte: to ? new Date(Number(to)) : undefined
}
},
include: {
invoice: true,
withdrawal: true
},
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }
]
})
nextCursor = nextCursorEncoded(decodedCursor, logs.length)
} else {
logs = await models.walletLog.findMany({
where: {
userId: me.id,
wallet: type ?? undefined,
createdAt: {
lte: decodedCursor.time
}
},
include: {
invoice: true,
withdrawal: true
},
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }
],
take: LIMIT,
skip: decodedCursor.offset
})
nextCursor = logs.length === LIMIT ? nextCursorEncoded(decodedCursor, logs.length) : null
}
return {
cursor: nextCursor,
entries: logs
}
},
failedInvoices: async (parent, args, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
@ -305,20 +481,38 @@ const resolvers = {
AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval
AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval
AND "paymentAttempt" < ${WALLET_MAX_RETRIES}
AND (
"actionType" = 'ITEM_CREATE' OR
"actionType" = 'ZAP' OR
"actionType" = 'DOWN_ZAP' OR
"actionType" = 'POLL_VOTE' OR
"actionType" = 'BOOST'
)
ORDER BY id DESC`
}
},
Wallet: {
wallet: async (wallet) => {
return {
...wallet.wallet,
__resolveType: generateTypeDefName(wallet.type)
}
}
},
WalletDetails: {
__resolveType: wallet => wallet.__resolveType
},
InvoiceOrDirect: {
__resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType
},
Mutation: {
createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => {
await validateSchema(amountSchema, { amount })
await assertGofacYourself({ models, headers })
const { invoice, paymentMethod } = await performPaidAction('RECEIVE', {
msats: satsToMsats(amount)
}, { models, lnd, me })
return {
...invoice,
__resolveType:
paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT ? 'Direct' : 'Invoice'
}
},
createWithdrawl: createWithdrawal,
sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => {
@ -379,6 +573,43 @@ const resolvers = {
return true
},
setWalletPriority: async (parent, { id, priority }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.wallet.update({ where: { userId: me.id, id: Number(id) }, data: { priority } })
return true
},
removeWallet: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const wallet = await models.wallet.findUnique({ where: { userId: me.id, id: Number(id) } })
if (!wallet) {
throw new GqlInputError('wallet not found')
}
const logger = walletLogger({ wallet, models })
await models.wallet.delete({ where: { userId: me.id, id: Number(id) } })
if (canReceive({ def: getWalletByType(wallet.type), config: wallet.wallet })) {
logger.info('details for receiving deleted')
}
return true
},
deleteWalletLogs: async (parent, { wallet }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.walletLog.deleteMany({ where: { userId: me.id, wallet } })
return true
},
buyCredits: async (parent, { credits }, { me, models, lnd }) => {
return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd })
}
@ -522,12 +753,223 @@ const resolvers = {
return item
},
sats: fact => msatsToSatsDecimal(fact.msats)
},
WalletLogEntry: {
context: async ({ level, context, invoice, withdrawal }, args, { models }) => {
const isError = ['error', 'warn'].includes(level.toLowerCase())
if (withdrawal) {
return {
...await logContextFromBolt11(withdrawal.bolt11),
...(withdrawal.preimage ? { preimage: withdrawal.preimage } : {}),
...(isError ? { max_fee: formatMsats(withdrawal.msatsFeePaying) } : {})
}
}
// XXX never return invoice as context because it might leak sensitive sender details
// if (invoice) { ... }
return context
}
}
}
export default 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 })
await validateSchema(withdrawlSchema, { invoice, maxFee })
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')
}
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 }) {
if (!me) {
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 })
}
async function fetchLnAddrInvoice (
export async function fetchLnAddrInvoice (
{ addr, amount, maxFee, comment, ...payer },
{ me, models, lnd }) {
{
me, models, lnd, autoWithdraw = false
}) {
const options = await lnAddrOptions(addr)
await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
@ -637,6 +1081,14 @@ async function fetchLnAddrInvoice (
// decode invoice
try {
const decoded = await parsePaymentRequest({ request: res.pr })
const ourPubkey = await getOurPubkey({ lnd })
if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
// unset lnaddr so we don't trigger another withdrawal with same destination
await models.wallet.deleteMany({
where: { userId: me.id, type: 'LIGHTNING_ADDRESS' }
})
throw new Error('automated withdrawals to other stackers are not allowed')
}
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
throw new Error('invoice has incorrect amount')
}

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 { NOFOLLOW_LIMIT } from '@/lib/constants'
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 }) {
const session = req && await getServerSession(req, res, getAuthOptions(req))
@ -156,7 +156,7 @@ export function getGetServerSideProps (
// required to redirect to /signup on page reload
// if we switched to anon and authentication is required
if (req.cookies[MULTI_AUTH_POINTER] === MULTI_AUTH_ANON) {
if (req.cookies[MULTI_AUTH_LIST] === MULTI_AUTH_ANON) {
me = null
}

View File

@ -18,6 +18,7 @@ import admin from './admin'
import blockHeight from './blockHeight'
import chainFee from './chainFee'
import paidAction from './paidAction'
import vault from './vault'
const common = gql`
type Query {
@ -38,4 +39,4 @@ const common = gql`
`
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction]
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!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int!
newComments(rootId: ID, after: Date): Comments!
}
type BoostPositions {
@ -58,7 +57,7 @@ export default gql`
text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction!
upsertPoll(
id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date,
randPollOptions: Boolean, hash: String, hmac: String): ItemPaidAction!
hash: String, hmac: String): ItemPaidAction!
updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction!
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
@ -82,7 +81,6 @@ export default gql`
meInvoiceActionState: InvoiceActionState
count: Int!
options: [PollOption!]!
randPollOptions: Boolean
}
type Items {
@ -149,7 +147,6 @@ export default gql`
ncomments: Int!
nDirectComments: Int!
comments(sort: String, cursor: String): Comments!
injected: Boolean!
path: String
position: Int
prior: Int

View File

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

View File

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

View File

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

View File

@ -29,34 +29,21 @@ export default gql`
users: [User!]!
}
input CropData {
x: Float!
y: Float!
width: Float!
height: Float!
originalWidth: Int!
originalHeight: Int!
scale: Float!
}
extend type Mutation {
setName(name: String!): String
setSettings(settings: SettingsInput!): User
cropPhoto(photoId: ID!, cropData: CropData): String!
setPhoto(photoId: ID!): Int!
upsertBio(text: String!): ItemPaidAction!
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
unlinkAuth(authType: String!): AuthMethods!
linkUnverifiedEmail(email: String!): Boolean
hideWelcomeBanner: Boolean
hideWalletRecvPrompt: Boolean
subscribeUserPosts(id: ID): User
subscribeUserComments(id: ID): User
toggleMute(id: ID): User
generateApiKey(id: ID!): String
deleteApiKey(id: ID!): User
disableFreebies: Boolean
setDiagnostics(diagnostics: Boolean!): Boolean
}
type User {
@ -87,6 +74,7 @@ export default gql`
input SettingsInput {
autoDropBolt11s: Boolean!
diagnostics: Boolean!
noReferralLinks: Boolean!
fiatCurrency: String!
satsFilter: Int!
@ -124,6 +112,10 @@ export default gql`
zapUndos: Int
wildWestMode: Boolean!
withdrawMaxFeeDefault: Int!
proxyReceive: Boolean
directReceive: Boolean
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
}
type AuthMethods {
@ -149,18 +141,16 @@ export default gql`
"""
lastCheckedJobs: String
hideWelcomeBanner: Boolean!
hideWalletRecvPrompt: Boolean!
tipPopover: Boolean!
upvotePopover: Boolean!
hasInvites: Boolean!
apiKeyEnabled: Boolean!
showPassphrase: Boolean!
diagnostics: Boolean!
"""
mirrors SettingsInput
"""
autoDropBolt11s: Boolean!
diagnostics: Boolean!
noReferralLinks: Boolean!
fiatCurrency: String!
satsFilter: Int!
@ -201,9 +191,14 @@ export default gql`
wildWestMode: Boolean!
withdrawMaxFeeDefault: Int!
autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Float
autoWithdrawMaxFeeTotal: Int
vaultKeyHash: String
vaultKeyHashUpdatedAt: Date
walletsUpdatedAt: Date
proxyReceive: Boolean
directReceive: Boolean
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
}
type UserOptional {
@ -216,9 +211,6 @@ export default gql`
streak: Int
gunStreak: Int
horseStreak: Int
hasSendWallet: Boolean
hasRecvWallet: Boolean
hideWalletRecvPrompt: Boolean
maxStreak: Int
isContributor: Boolean
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 { 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 {
invoice(id: ID!): Invoice!
withdrawl(id: ID!): Withdrawl!
@ -10,151 +68,23 @@ const typeDefs = gql`
numBolt11s: Int!
connectAddress: String!
walletHistory(cursor: String, inc: String): History
wallets: [WalletOrTemplate!]!
wallet(id: ID, name: String): WalletOrTemplate
walletSettings: WalletSettings!
walletLogs(protocolId: Int, cursor: String, debug: Boolean): WalletLogs!
wallets(includeReceivers: Boolean, includeSenders: Boolean, onlyEnabled: Boolean, prioritySort: String): [Wallet!]!
wallet(id: ID!): Wallet
walletByType(type: String!): Wallet
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
failedInvoices: [Invoice!]!
}
extend type Mutation {
createInvoice(amount: Int!): InvoiceOrDirect!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
cancelInvoice(hash: String!, hmac: String, userCancel: Boolean): Invoice!
dropBolt11(hash: String!): Boolean
buyCredits(credits: Int!): BuyCreditsPaidAction!
# upserts
upsertWalletSendLNbits(
${shared},
url: String!,
apiKey: VaultEntryInput!
): WalletSendLNbits!
upsertWalletRecvLNbits(
${shared},
url: String!,
apiKey: String!
): WalletRecvLNbits!
upsertWalletSendPhoenixd(
${shared},
url: String!,
apiKey: VaultEntryInput!
): WalletSendPhoenixd!
upsertWalletRecvPhoenixd(
${shared},
url: String!,
apiKey: String!
): WalletRecvPhoenixd!
upsertWalletSendBlink(
${shared},
currency: VaultEntryInput!,
apiKey: VaultEntryInput!
): WalletSendBlink!
upsertWalletRecvBlink(
${shared},
currency: String!,
apiKey: String!
): WalletRecvBlink!
upsertWalletRecvLightningAddress(
${shared},
address: String!
): WalletRecvLightningAddress!
upsertWalletSendNWC(
${shared},
url: VaultEntryInput!
): WalletSendNWC!
upsertWalletRecvNWC(
${shared},
url: String!
): WalletRecvNWC!
upsertWalletRecvCLNRest(
${shared},
socket: String!,
rune: String!,
cert: String
): WalletRecvCLNRest!
upsertWalletRecvLNDGRPC(
${shared},
socket: String!,
macaroon: String!,
cert: String
): WalletRecvLNDGRPC!
upsertWalletSendLNC(
${shared},
pairingPhrase: VaultEntryInput!,
localKey: VaultEntryInput!,
remoteKey: VaultEntryInput!,
serverHost: VaultEntryInput!
): WalletSendLNC!
upsertWalletSendWebLN(
${shared}
): WalletSendWebLN!
# tests
testWalletRecvNWC(
url: String!
): Boolean!
testWalletRecvLightningAddress(
address: String!
): Boolean!
testWalletRecvCLNRest(
socket: String!,
rune: String!,
cert: String
): Boolean!
testWalletRecvLNDGRPC(
socket: String!,
macaroon: String!,
cert: String
): Boolean!
testWalletRecvPhoenixd(
url: String!
apiKey: String!
): Boolean!
testWalletRecvLNbits(
url: String!
apiKey: String!
): Boolean!
testWalletRecvBlink(
currency: String!
apiKey: String!
): Boolean!
# delete
removeWallet(id: ID!): Boolean
removeWalletProtocol(id: ID!): Boolean
# crypto
updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean
updateKeyHash(keyHash: String!): Boolean
resetWallets(newKeyHash: String!): Boolean
disablePassphraseExport: Boolean
# settings
setWalletSettings(settings: WalletSettingsInput!): Boolean
setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean
# logs
addWalletLog(protocolId: Int, level: String!, message: String!, timestamp: Date!, invoiceId: Int): Boolean
deleteWalletLogs(protocolId: Int, debug: Boolean): Boolean
deleteWalletLogs(wallet: String): Boolean
setWalletPriority(id: ID!, priority: Int!): Boolean
buyCredits(credits: Int!): BuyCreditsPaidAction!
}
type BuyCreditsResult {
@ -165,155 +95,15 @@ const typeDefs = gql`
id: ID!
}
union WalletOrTemplate = Wallet | WalletTemplate
enum WalletStatus {
OK
WARNING
ERROR
DISABLED
}
type Wallet {
id: ID!
name: String!
priority: Int!
template: WalletTemplate!
protocols: [WalletProtocol!]!
send: WalletStatus!
receive: WalletStatus!
}
type WalletTemplate {
name: ID!
protocols: [WalletProtocolTemplate!]!
send: WalletStatus!
receive: WalletStatus!
}
type WalletProtocol {
id: ID!
name: String!
send: Boolean!
createdAt: Date!
updatedAt: Date!
type: String!
enabled: Boolean!
config: WalletProtocolConfig!
status: WalletStatus!
}
type WalletProtocolTemplate {
id: ID!
name: String!
send: Boolean!
}
union WalletProtocolConfig =
| WalletSendNWC
| WalletSendLNbits
| WalletSendPhoenixd
| WalletSendBlink
| WalletSendWebLN
| WalletSendLNC
| WalletRecvNWC
| WalletRecvLNbits
| WalletRecvPhoenixd
| WalletRecvBlink
| WalletRecvLightningAddress
| WalletRecvCLNRest
| WalletRecvLNDGRPC
type WalletSettings {
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Float
autoWithdrawMaxFeeTotal: Int
proxyReceive: Boolean!
}
input WalletSettingsInput {
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
autoWithdrawThreshold: Int!
autoWithdrawMaxFeePercent: Float!
autoWithdrawMaxFeeTotal: Int!
proxyReceive: Boolean!
}
type WalletSendNWC {
id: ID!
url: VaultEntry!
}
type WalletSendLNbits {
id: ID!
url: String!
apiKey: VaultEntry!
}
type WalletSendPhoenixd {
id: ID!
url: String!
apiKey: VaultEntry!
}
type WalletSendBlink {
id: ID!
currency: VaultEntry!
apiKey: VaultEntry!
}
type WalletSendWebLN {
id: ID!
}
type WalletSendLNC {
id: ID!
pairingPhrase: VaultEntry!
localKey: VaultEntry!
remoteKey: VaultEntry!
serverHost: VaultEntry!
}
type WalletRecvNWC {
id: ID!
url: String!
}
type WalletRecvLNbits {
id: ID!
url: String!
apiKey: String!
}
type WalletRecvPhoenixd {
id: ID!
url: String!
apiKey: String!
}
type WalletRecvBlink {
id: ID!
currency: String!
apiKey: String!
}
type WalletRecvLightningAddress {
id: ID!
address: String!
}
type WalletRecvCLNRest {
id: ID!
socket: String!
rune: String!
cert: String
}
type WalletRecvLNDGRPC {
id: ID!
socket: String!
macaroon: String!
cert: String
priority: Int!
wallet: WalletDetails!
vaultEntries: [VaultEntry!]!
}
input AutowithdrawSettings {
@ -322,22 +112,6 @@ const typeDefs = gql`
autoWithdrawMaxFeeTotal: Int!
}
input WalletEncryptionUpdate {
id: ID!
protocols: [WalletEncryptionUpdateProtocol!]!
}
input WalletEncryptionUpdateProtocol {
name: String!
send: Boolean!
config: JSONObject!
}
input WalletPriorityUpdate {
id: ID!
priority: Int!
}
type Invoice implements InvoiceOrDirect {
id: ID!
createdAt: Date!
@ -412,7 +186,7 @@ const typeDefs = gql`
cursor: String
}
type WalletLogs {
type WalletLog {
entries: [WalletLogEntry!]!
cursor: String
}
@ -420,25 +194,11 @@ const typeDefs = gql`
type WalletLogEntry {
id: ID!
createdAt: Date!
wallet: Wallet
protocol: WalletProtocol
wallet: ID!
level: String!
message: String!
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
Scroogey-SN,pr,#1911,#1905,good-first-issue,,,1,18k,Scroogey@coinos.io,2025-03-10
Scroogey-SN,pr,#1928,#1924,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10
dtonon,issue,#1928,#1924,good-first-issue,,,,2k,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,issue,#1926,#1914,medium-hard,,,,50k,simplestacker@getalby.com,2025-03-10
ed-kung,pr,#1926,#1927,easy,,,,100k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1926,#1927,easy,,,,10k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1913,#1890,good-first-issue,,,,2k,simplestacker@getalby.com,2025-03-10
Scroogey-SN,pr,#1930,#1167,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10
itsrealfake,issue,#1930,#1167,good-first-issue,,,,2k,smallimagination100035@getalby.com,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
felipebueno,issue,#1947,#1945,good-first-issue,,,,2k,felipebueno@blink.sv,2025-03-10
ed-kung,pr,#1952,#1951,easy,,,,100k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1952,#1951,easy,,,,10k,simplestacker@getalby.com,2025-03-10
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,2025-04-02
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,me@benthecarman.com,2025-04-16
ed-kung,pr,#2012,#2004,easy,,,,100k,simplestacker@getalby.com,2025-04-02
ed-kung,issue,#2012,#2004,easy,,,,10k,simplestacker@getalby.com,2025-04-02
ed-kung,pr,#1993,#1982,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
rideandslide,issue,#1993,#1982,good-first-issue,,,,2k,koiora@getalby.com,2025-04-02
ed-kung,pr,#1972,#1254,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
SatsAllDay,issue,#1972,#1254,good-first-issue,,,,2k,weareallsatoshi@getalby.com,2025-04-02
ed-kung,pr,#1962,#1343,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
ed-kung,pr,#1962,#1217,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
ed-kung,pr,#1962,#866,easy,,,,100k,simplestacker@getalby.com,2025-04-02
felipebueno,issue,#1962,#866,easy,,,,10k,felipebueno@blink.sv,2025-04-02
cointastical,issue,#1962,#1217,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
Scroogey-SN,pr,#1975,#1964,good-first-issue,,,,20k,Scroogey@coinos.io,2025-04-02
rideandslide,issue,#1986,#1985,good-first-issue,,,,2k,koiora@getalby.com,2025-04-02
kristapsk,issue,#1976,#841,good-first-issue,,,,2k,kristapsk@stacker.news,2025-04-16
ed-kung,pr,#2070,#2061,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-16
ed-kung,issue,#2070,#2061,good-first-issue,,,,2k,simplestacker@getalby.com,2025-04-16
ed-kung,pr,#2070,#2058,easy,,,,100k,simplestacker@getalby.com,2025-04-16
ed-kung,pr,#2070,#2047,medium-hard,,,,500k,simplestacker@getalby.com,2025-04-16
SouthKoreaLN,pr,#2068,#2064,good-first-issue,,,,20k,south_korea_ln@stacker.news,2025-04-16
kepford,issue,#2068,#2064,good-first-issue,,,,2k,penalwink141@minibits.cash,2025-04-16
SouthKoreaLN,pr,#2069,#1990,good-first-issue,,,,20k,south_korea_ln@stacker.news,2025-04-16
cointastical,issue,#2071,#1475,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
brymut,pr,#2082,#2051,easy,low,3,,35k,brymut@stacker.news,2025-04-16
abhiShandy,pr,#2083,#1270,good-first-issue,,,,20k,abhishandy@stacker.news,2025-04-16
cointastical,issue,#2083,#1270,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
brymut,pr,#2093,#1991,easy,,,,100k,brymut@stacker.news,2025-04-16
brymut,pr,#2100,#2090,easy,,3,,70k,brymut@stacker.news,2025-04-24
abhiShandy,pr,#2109,#1221,good-first-issue,,,,20k,abhishandy@stacker.news,2025-04-24
Gudnessuche,issue,#2109,#1221,good-first-issue,,,,2k,everythingsatoshi@getalby.com,2025-04-24
brymut,pr,#2153,#2087,easy,,,,100k,brymut@stacker.news,2025-05-13
brymut,pr,#2152,#2142,good-first-issue,,1,,18k,brymut@stacker.news,2025-05-13
m0wer,pr,#2124,#992,medium,,1,,225k,klk@stacker.news,2025-05-13
ed-kung,issue,#2072,#2043,easy,,,,10k,simplestacker@getalby.com,2025-05-15
ed-kung,helpfulness,#2072,#2043,easy,,,,10k,simplestacker@getalby.com,2025-05-15
SouthKoreaLN,pr,#2072,#2043,easy,,,,100k,south_korea_ln@stacker.news,2025-05-13
m0wer,pr,#2135,#1391,easy,,1,more difficult than planned,150k,klk@stacker.news,2025-05-13
sutt,pr,#2162,#2161,good-first-issue,,,,20k,bounty_hunter@stacker.news,2025-05-15
sutt,issue,#2162,#2161,good-first-issue,,,,2k,bounty_hunter@stacker.news,2025-05-15
brymut,pr,#2171,#2164,easy,,,,100k,brymut@stacker.news,2025-05-21
SouthKoreaLN,issue,#2171,#2164,easy,,,,10k,south_korea_ln@stacker.news,2025-05-21
brymut,pr,#2175,#2173,good-first-issue,,,,20k,brymut@stacker.news,2025-05-21
sutt,pr,#2185,#2183,easy,high,,,200k,bounty_hunter@stacker.news,2025-06-19
sutt,issue,#2185,#2183,easy,high,,,20k,bounty_hunter@stacker.news,2025-06-06
axelvyrn,advisory,#2205,GHSA-x2xp-x867-4jfc,,,,,100k,holonite@speed.app,2025-06-06
brymut,pr,#2184,#2165,easy,,,,100k,brymut@stacker.news,2025-06-06
sutt,pr,#2190,#2187,easy,,,,100k,bounty_hunter@stacker.news,2025-06-06
sutt,issue,#2190,#2187,easy,,,,10k,bounty_hunter@stacker.news,2025-06-06
sutt,pr,#2192,#2188,medium,,,,250k,bounty_hunter@stacker.news,2025-06-19
sutt,issue,#2192,#2188,medium,,,,25k,bounty_hunter@stacker.news,2025-06-19
abhiShandy,pr,#2195,#2181,good-first-issue,,1,,18k,abhishandy@stacker.news,2025-06-13
brymut,pr,#2191,#1409,medium,,2,,200k,brymut@stacker.news,2025-06-13
SatsAllDay,issue,#2191,#1409,medium,,,,20k,weareallsatoshi@getalby.com,2025-06-13
ed-kung,pr,#2217,#2039,medium,,,,250k,simplestacker@getalby.com,2025-06-18
ed-kung,issue,#2217,#2039,medium,,,,25k,simplestacker@getalby.com,2025-06-18
axelvyrn,pr,#2220,#2198,good-first-issue,,5,,10k,holonite@speed.app,2025-06-18
axelvyrn,issue,#2220,#2198,good-first-issue,,,,1k,holonite@speed.app,2025-06-18
brymut,pr,#2221,#2204,good-first-issue,,,,20k,brymut@stacker.news,2025-06-18
brymut,pr,#2235,#2233,good-first-issue,,,,20k,brymut@stacker.news,2025-06-18
brymut,pr,#2250,#2106,good-first-issue,,,,20k,brymut@stacker.news,2025-07-12
SouthKoreaLN,issue,#2267,#2164,easy,,,,10k,south_korea_ln@stacker.news,2025-07-12
pory-gone,pr,#2316,#2277,good-first-issue,,,,20k,pory@porygone.xyz,2025-08-01
brymut,pr,#2326,,good-first-issue,,,,20k,brymut@stacker.news,2025-07-31
brymut,pr,#2332,#2276,easy,,,,100k,brymut@stacker.news,2025-07-31
ed-kung,pr,#2373,#2371,good-first-issue,,,,20k,simplestacker@getalby.com,2025-07-31
ed-kung,issue,#2373,#2371,good-first-issue,,,,2k,simplestacker@getalby.com,2025-07-31
pory-gone,pr,#2381,#2370,good-first-issue,,,,20k,pory@porygone.xyz,???
pory-gone,pr,#2413,#2361,easy,,,,100k,pory@porygone.xyz,???
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,???
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,???,???
ed-kung,pr,#2012,#2004,easy,,,,100k,simplestacker@getalby.com,???
ed-kung,issue,#2012,#2004,easy,,,,10k,simplestacker@getalby.com,???
ed-kung,pr,#1993,#1982,good-first-issue,,,,20k,simplestacker@getalby.com,???
rideandslide,issue,#1993,#1982,good-first-issue,,,,2k,???,???
ed-kung,pr,#1972,#1254,good-first-issue,,,,20k,simplestacker@getalby.com,???
SatsAllDay,issue,#1972,#1254,good-first-issue,,,,2k,weareallsatoshi@getalby.com,???
ed-kung,pr,#1962,#1343,good-first-issue,,,,20k,simplestacker@getalby.com,???
ed-kung,pr,#1962,#1217,good-first-issue,,,,20k,simplestacker@getalby.com,???
ed-kung,pr,#1962,#866,easy,,,,100k,simplestacker@getalby.com,???
felipebueno,issue,#1962,#866,easy,,,,10k,felipebueno@blink.sv,???
cointastical,issue,#1962,#1217,good-first-issue,,,,2k,cointastical@stacker.news,???
Scroogey-SN,pr,#1975,#1964,good-first-issue,,,,20k,Scroogey@coinos.io,???
rideandslide,issue,#1986,#1985,good-first-issue,,,,2k,???,???
kristapsk,issue,#1976,#841,good-first-issue,,,,2k,???,???

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": "",
"license": "ISC",
"dependencies": {
"express": "^4.20.0",
"express": "^4.18.2",
"puppeteer": "^20.8.2"
},
"type": "module"

View File

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

View File

@ -1,44 +1,165 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
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 { useQuery } from '@apollo/client'
import { UserListRow } from '@/components/user-list'
import useCookie from '@/components/use-cookie'
import Link from 'next/link'
import AddIcon from '@/svgs/add-fill.svg'
import { MultiAuthErrorBanner } from '@/components/banners'
import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_LIST, MULTI_AUTH_POINTER } from '@/lib/auth'
const AccountContext = createContext()
const CHECK_ERRORS_INTERVAL_MS = 5_000
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
export const nextAccount = async () => {
const { status } = await fetch('/api/next-account', { credentials: 'include' })
// if status is 302, this means the server was able to switch us to the next available account
return status === 302
export const AccountProvider = ({ children }) => {
const [accounts, setAccounts] = useState([])
const [meAnon, setMeAnon] = useState(true)
const [errors, setErrors] = useState([])
const updateAccountsFromCookie = useCallback(() => {
const { [MULTI_AUTH_LIST]: listCookie } = cookie.parse(document.cookie)
const accounts = listCookie
? JSON.parse(b64Decode(listCookie))
: []
setAccounts(accounts)
}, [])
const nextAccount = useCallback(async () => {
const { status } = await fetch('/api/next-account', { credentials: 'include' })
// if status is 302, this means the server was able to switch us to the next available account
// and the current account was simply removed from the list of available accounts including the corresponding JWT.
const switchSuccess = status === 302
if (switchSuccess) updateAccountsFromCookie()
return switchSuccess
}, [updateAccountsFromCookie])
const checkErrors = useCallback(() => {
const {
[MULTI_AUTH_LIST]: listCookie,
[MULTI_AUTH_POINTER]: pointerCookie
} = cookie.parse(document.cookie)
const errors = []
if (!listCookie) errors.push(`${MULTI_AUTH_LIST} cookie not found`)
if (!pointerCookie) errors.push(`${MULTI_AUTH_POINTER} cookie not found`)
setErrors(errors)
}, [])
useEffect(() => {
if (SSR) return
updateAccountsFromCookie()
const { [MULTI_AUTH_POINTER]: pointerCookie } = cookie.parse(document.cookie)
setMeAnon(pointerCookie === 'anonymous')
const interval = setInterval(checkErrors, CHECK_ERRORS_INTERVAL_MS)
return () => clearInterval(interval)
}, [updateAccountsFromCookie, checkErrors])
const value = useMemo(
() => ({
accounts,
meAnon,
setMeAnon,
nextAccount,
multiAuthErrors: errors
}),
[accounts, meAnon, setMeAnon, nextAccount])
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
}
export const useAccounts = () => useContext(AccountContext)
const AccountListRow = ({ account, ...props }) => {
const { meAnon, setMeAnon } = useAccounts()
const { me, refreshMe } = useMe()
const anonRow = account.id === USER_ID.anon
const selected = (meAnon && anonRow) || Number(me?.id) === Number(account.id)
const router = useRouter()
// fetch updated names and photo ids since they might have changed since we were issued the JWTs
const { data, error } = useQuery(USER,
{
variables: { id: account.id }
}
)
if (error) console.error(`query for user ${account.id} failed:`, error)
const name = data?.user?.name || account.name
const photoId = data?.user?.photoId || account.photoId
const onClick = async (e) => {
// prevent navigation
e.preventDefault()
// update pointer cookie
const options = cookieOptions({ httpOnly: false })
document.cookie = cookie.serialize(MULTI_AUTH_POINTER, anonRow ? MULTI_AUTH_ANON : account.id, options)
// update state
if (anonRow) {
// order is important to prevent flashes of no session
setMeAnon(true)
await refreshMe()
} else {
await refreshMe()
// order is important to prevent flashes of inconsistent data in switch account dialog
setMeAnon(account.id === USER_ID.anon)
}
// reload whatever page we're on to avoid any bugs due to missing authorization etc.
router.reload()
}
return (
<div className='d-flex flex-row'>
<UserListRow
user={{ ...account, photoId, name }}
className='d-flex align-items-center me-2'
{...props}
onNymClick={onClick}
selected={selected}
/>
</div>
)
}
export default function SwitchAccountList () {
const { accounts, multiAuthErrors } = useAccounts()
const router = useRouter()
const accounts = useAccounts()
const [pointerCookie] = useCookie(MULTI_AUTH_POINTER)
const hasError = multiAuthErrors.length > 0
if (hasError) {
return (
<>
<div className='my-2'>
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
<MultiAuthErrorBanner errors={multiAuthErrors} />
</div>
</div>
</>
)
}
// can't show hat since the streak is not included in the JWT payload
return (
<>
<div className='my-2'>
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
<h4 className='text-muted'>Accounts</h4>
<AccountListRow
account={{ id: USER_ID.anon, name: 'anon' }}
selected={pointerCookie === MULTI_AUTH_ANON}
showHat={false}
/>
<AccountListRow account={{ id: USER_ID.anon, name: 'anon' }} showHat={false} />
{
accounts.map((account) =>
<AccountListRow
key={account.id}
account={account}
selected={Number(pointerCookie) === account.id}
showHat={false}
/>)
accounts.map((account) => <AccountListRow key={account.id} account={account} showHat={false} />)
}
</div>
<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 { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
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 Info from './info'
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 across all territories over the last 30 days is pinned to the top of the homepage</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
<li>The maximum boost is {numWithUnits(BOOST_MAX, { abbreviate: false })}</li>
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker (very rare)
<ul>
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li>
@ -198,7 +197,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
for (let i = 0; i < MAX_FORWARDS; i++) {
['nym', 'pct'].forEach(key => {
const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`)
if (value !== undefined && value !== null) {
if (value) {
formik?.setFieldValue(`forward[${i}].${key}`, value)
}
})
@ -269,7 +268,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
emptyItem={EMPTY_FORWARD}
hint={<span className='text-muted'>Forward sats to up to 5 other stackers. Any remaining sats go to you.</span>}
>
{({ index, AppendColumn }) => {
{({ index, placeholder }) => {
return (
<div key={index} className='d-flex flex-row'>
<InputUserSuggest
@ -286,7 +285,6 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
max={100}
append={<InputGroup.Text className='text-monospace'>%</InputGroup.Text>}
groupClassName={`${styles.percent} mb-0`}
AppendColumn={AppendColumn}
/>
</div>
)

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 { useShowModal } from './modal'
import { FileUpload } from './file-upload'
import { gql, useMutation } from '@apollo/client'
export default function Avatar ({ onSuccess }) {
const [cropPhoto] = useMutation(gql`
mutation cropPhoto($photoId: ID!, $cropData: CropData) {
cropPhoto(photoId: $photoId, cropData: $cropData)
}
`)
const [uploading, setUploading] = useState()
const showModal = useShowModal()
const Body = ({ onClose, file, onSave }) => {
const Body = ({ onClose, file, upload }) => {
const [scale, setScale] = useState(1)
const ref = useRef()
@ -40,21 +34,13 @@ export default function Avatar ({ onSuccess }) {
/>
</BootstrapForm.Group>
<Button
onClick={async () => {
const rect = ref.current.getCroppingRect()
const img = new window.Image()
img.onload = async () => {
const cropData = {
...rect,
originalWidth: img.width,
originalHeight: img.height,
scale
onClick={() => {
ref.current.getImageScaledToCanvas().toBlob(blob => {
if (blob) {
upload(blob)
onClose()
}
// upload original to S3 along with crop data
await onSave(cropData)
}
img.src = URL.createObjectURL(file)
onClose()
}, 'image/jpeg')
}}
>save
</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 (
<FileUpload
allow='image/*'
@ -109,7 +56,26 @@ export default function Avatar ({ onSuccess }) {
console.log(e)
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={() => {
setUploading(true)
}}

View File

@ -1,14 +1,29 @@
import { Fragment } from 'react'
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
import Tooltip from 'react-bootstrap/Tooltip'
import CowboyHatIcon from '@/svgs/cowboy.svg'
import AnonIcon from '@/svgs/spy-fill.svg'
import GunIcon from '@/svgs/revolver.svg'
import HorseIcon from '@/svgs/horse.svg'
import { numWithUnits } from '@/lib/format'
import { USER_ID } from '@/lib/constants'
import GunIcon from '@/svgs/revolver.svg'
import HorseIcon from '@/svgs/horse.svg'
import classNames from 'classnames'
const BADGES = [
{
icon: CowboyHatIcon,
streakName: 'streak'
},
{
icon: HorseIcon,
streakName: 'horseStreak'
},
{
icon: GunIcon,
streakName: 'gunStreak',
sizeDelta: 2
}
]
export default function Badges ({ user, badge, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) {
if (!user || Number(user.id) === USER_ID.ad) return null
if (Number(user.id) === USER_ID.anon) {
@ -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 (
<span className={className}>
{badges.map(({ icon, overlayText, sizeDelta }, i) => (
{BADGES.map(({ icon, streakName, sizeDelta }, i) => (
<SNBadge
key={i}
key={streakName}
user={user}
badge={badge}
overlayText={overlayText}
streakName={streakName}
badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
IconForBadge={icon}
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 }) {
let Wrapper = Fragment
if (overlayText) {
Wrapper = ({ children }) => (
<BadgeTooltip overlayText={overlayText}>{children}</BadgeTooltip>
)
function SNBadge ({ user, badge, streakName, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
const streak = user.optional[streakName]
if (streak === null) {
return null
}
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>
</Wrapper>
</BadgeTooltip>
)
}

View File

@ -5,6 +5,8 @@ import { useMe } from '@/components/me'
import { useMutation } from '@apollo/client'
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
import { useToast } from '@/components/toast'
import Link from 'next/link'
import AccordianItem from '@/components/accordian-item'
export function WelcomeBanner ({ Banner }) {
const { me } = useMe()
@ -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 () {
return (
<Alert className={`${styles.banner} mt-0`} key='info' variant='danger'>
@ -106,3 +124,24 @@ export function AuthBanner () {
</Alert>
)
}
export function MultiAuthErrorBanner ({ errors }) {
return (
<Alert className={styles.banner} key='info' variant='danger'>
<div className='fw-bold mb-3'>Account switching is currently unavailable</div>
<AccordianItem
className='my-3'
header='We have detected the following issues:'
headerColor='var(--bs-danger-text-emphasis)'
body={
<ul>
{errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
}
/>
<div className='mt-3'>To resolve these issues, please sign out and sign in again.</div>
</Alert>
)
}

View File

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

View File

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

View File

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

View File

@ -8,7 +8,6 @@ import { defaultCommentSort } from '@/lib/item'
import { useRouter } from 'next/router'
import MoreFooter from './more-footer'
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
import useLiveComments from './use-live-comments'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter()
@ -65,13 +64,10 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
export default function Comments ({
parentId, pinned, bio, parentCreatedAt,
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, lastCommentAt, item, ...props
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, ...props
}) {
const router = useRouter()
// fetch new comments that arrived after the lastCommentAt, and update the item.comments field in cache
useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort)
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
return (
@ -94,11 +90,11 @@ export default function Comments ({
: null}
{pins.map(item => (
<Fragment key={item.id}>
<Comment depth={1} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin />
<Comment depth={1} item={item} {...props} pin />
</Fragment>
))}
{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 &&
<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
if (provider === 'twitter') {
return (
<>
<div className={classNames(styles.twitterContainer, !show && styles.twitterContained, className)}>
<TwitterTweetEmbed
tweetId={id}
options={{ theme: darkMode ? 'dark' : 'light', width: topLevel ? '550px' : '350px' }}
key={darkMode ? '1' : '2'}
placeholder={<TweetSkeleton className={className} />}
onLoad={() => setOverflowing(true)}
/>
{overflowing && !show &&
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
show full tweet
</Button>}
</div>
</>
<div className={classNames(styles.twitterContainer, !show && styles.twitterContained, className)}>
<TwitterTweetEmbed
tweetId={id}
options={{ theme: darkMode ? 'dark' : 'light', width: topLevel ? '550px' : '350px' }}
key={darkMode ? '1' : '2'}
placeholder={<TweetSkeleton className={className} />}
onLoad={() => setOverflowing(true)}
/>
{overflowing && !show &&
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
show full tweet
</Button>}
</div>
)
}

View File

@ -3,6 +3,7 @@ import { StaticLayout } from './layout'
import styles from '@/styles/error.module.css'
import Image from 'react-bootstrap/Image'
import copy from 'clipboard-copy'
import { LoggerContext } from './logger'
import Button from 'react-bootstrap/Button'
import { useToast } from './toast'
import { decodeMinifiedStackTrace } from '@/lib/stacktrace'
@ -35,6 +36,8 @@ class ErrorBoundary extends Component {
// You can use your own error logging service here
console.log({ error, errorInfo })
this.setState({ errorInfo })
const logger = this.context
logger?.error(this.getErrorDetails())
}
render () {
@ -44,7 +47,7 @@ class ErrorBoundary extends Component {
const errorDetails = this.getErrorDetails()
return (
<StaticLayout footer={false}>
<Image width='500' height='375' className='rounded-1 shadow-sm' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.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>
{this.state.error && <CopyErrorButton errorDetails={errorDetails} />}
</StaticLayout>
@ -56,6 +59,8 @@ class ErrorBoundary extends Component {
}
}
ErrorBoundary.contextType = LoggerContext
export default ErrorBoundary
// 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 Amboss from '@/svgs/amboss.svg'
import Mempool from '@/svgs/bimi.svg'
import { useEffect, useState } from 'react'
import Rewards from './footer-rewards'
import useDarkMode from './dark-mode'
import ActionTooltip from './action-tooltip'
import { useAnimationEnabled } from '@/components/animation'
const RssPopover = (
<Popover>
@ -53,43 +53,33 @@ const RssPopover = (
const SocialsPopover = (
<Popover>
<Popover.Body style={{ fontWeight: 500, fontSize: '.9rem' }}>
<div className='d-flex justify-content-center'>
<a
href='https://njump.me/npub1jfujw6llhq7wuvu5detycdsq5v5yqf56sgrdq8wlgrryx2a2p09svwm0gx' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
nostr
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://twitter.com/stacker_news' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
twitter
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://www.youtube.com/@stackernews' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
youtube
</a>
</div>
<div className='d-flex justify-content-center'>
<a
href='https://www.fountain.fm/show/Mg1AWuvkeZSFhsJZ3BW2' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
pod
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://www.plebpoet.com/zines.html' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
zines
</a>
</div>
<a
href='https://njump.me/npub1jfujw6llhq7wuvu5detycdsq5v5yqf56sgrdq8wlgrryx2a2p09svwm0gx' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
nostr
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://twitter.com/stacker_news' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
twitter
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://www.youtube.com/@stackernews' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
youtube
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://www.fountain.fm/show/Mg1AWuvkeZSFhsJZ3BW2' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
pod
</a>
</Popover.Body>
</Popover>
)
@ -145,10 +135,24 @@ const LegalPopover = (
export default function Footer ({ links = true }) {
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 LnIcon = animationEnabled ? No : Bolt
const LnIcon = lightning === 'yes' ? No : Bolt
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`}>
<DarkModeIcon onClick={darkModeToggle} width={20} height={20} className='fill-grey theme' suppressHydrationWarning />
</ActionTooltip>
<ActionTooltip notForm overlayText={`${animationEnabled ? 'disable' : 'enable'} lightning animations`}>
<LnIcon onClick={toggleAnimation} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
<ActionTooltip notForm overlayText={`${lightning === 'yes' ? 'disable' : 'enable'} lightning animations`}>
<LnIcon onClick={toggleLightning} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
</ActionTooltip>
</div>
<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 { gql, useLazyQuery } from '@apollo/client'
import { USER_SUGGESTIONS } from '@/fragments/users'
import { SUB_SUGGESTIONS } from '@/fragments/subs'
import TextareaAutosize from 'react-textarea-autosize'
import { useToast } from './toast'
import { numWithUnits } from '@/lib/format'
@ -34,9 +33,12 @@ import Info from './info'
import { useMe } from './me'
import classNames from 'classnames'
import Clipboard from '@/svgs/clipboard-line.svg'
import QrIcon from '@/svgs/qr-code-line.svg'
import QrScanIcon from '@/svgs/qr-scan-line.svg'
import { useShowModal } from './modal'
import { QRCodeSVG } from 'qrcode.react'
import dynamic from 'next/dynamic'
import { qrImageSettings } from './qr'
import { useIsClient } from './use-client'
import PageLoading from './page-loading'
@ -75,7 +77,7 @@ export function SubmitButton ({
)
}
export function CopyButton ({ value, icon, ...props }) {
function CopyButton ({ value, icon, ...props }) {
const toaster = useToast()
const [copied, setCopied] = useState(false)
@ -137,174 +139,6 @@ function setNativeValue (textarea, value) {
textarea.dispatchEvent(new Event('input', { bubbles: true, value }))
}
function useEntityAutocomplete ({
prefix,
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent
}) {
const [entityData, setEntityData] = useState()
const handleSelect = useCallback((name) => {
if (entityData?.start === undefined || entityData?.end === undefined) return
const { start, end } = entityData
setEntityData(undefined)
const first = `${meta?.value.substring(0, start)}${prefix}${name}`
const second = meta?.value.substring(end)
const updatedValue = `${first}${second}`
helpers.setValue(updatedValue)
setSelectionRange({ start: first.length, end: first.length })
innerRef.current.focus()
}, [entityData, meta?.value, helpers, prefix, setSelectionRange, innerRef])
const handleTextChange = useCallback((e) => {
const { value, selectionStart } = e.target
if (!value || selectionStart === undefined) {
setEntityData(undefined)
return false
}
let priorSpace = -1
for (let i = selectionStart - 1; i >= 0; i--) {
if (/[^\w@~]/.test(value[i])) {
priorSpace = i
break
}
}
let nextSpace = value.length
for (let i = selectionStart; i <= value.length; i++) {
if (/[^\w]/.test(value[i])) {
nextSpace = i
break
}
}
const currentSegment = value.substring(priorSpace + 1, nextSpace)
const regexPattern = new RegExp(`^\\${prefix}\\w*$`)
if (regexPattern.test(currentSegment)) {
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
setEntityData({
query: currentSegment,
start: priorSpace + 1,
end: nextSpace,
style: {
position: 'absolute',
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
left: `${left}px`
}
})
return true
}
setEntityData(undefined)
return false
}, [prefix])
// Return a function that takes a render prop instead of directly returning the component
return {
entityData,
handleSelect,
handleTextChange,
renderSuggest: (renderProps) => {
if (!entityData) return null
return (
<SuggestComponent
query={entityData?.query}
onSelect={handleSelect}
dropdownStyle={entityData?.style}
>
{renderProps}
</SuggestComponent>
)
}
}
}
export function useDualAutocomplete ({ meta, helpers, innerRef, setSelectionRange }) {
const userAutocomplete = useEntityAutocomplete({
prefix: '@',
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: UserSuggest
})
const territoryAutocomplete = useEntityAutocomplete({
prefix: '~',
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: TerritorySuggest
})
const handleTextChange = useCallback((e) => {
// Try to match user mentions first, then territories
if (!userAutocomplete.handleTextChange(e)) {
territoryAutocomplete.handleTextChange(e)
}
}, [userAutocomplete, territoryAutocomplete])
const handleKeyDown = useCallback((e, userOnKeyDown, territoryOnKeyDown) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (!metaOrCtrl) {
if (userAutocomplete.entityData) {
return userOnKeyDown(e)
} else if (territoryAutocomplete.entityData) {
return territoryOnKeyDown(e)
}
}
return false // Didn't handle the event
}, [userAutocomplete.entityData, territoryAutocomplete.entityData])
const handleBlur = useCallback((resetUserSuggestions, resetTerritorySuggestions) => {
setTimeout(resetUserSuggestions, 500)
setTimeout(resetTerritorySuggestions, 500)
}, [])
return {
userAutocomplete,
territoryAutocomplete,
handleTextChange,
handleKeyDown,
handleBlur
}
}
export function DualAutocompleteWrapper ({
userAutocomplete,
territoryAutocomplete,
children
}) {
return (
<UserSuggest
query={userAutocomplete.entityData?.query}
onSelect={userAutocomplete.handleSelect}
dropdownStyle={userAutocomplete.entityData?.style}
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions: resetUserSuggestions }) => (
<TerritorySuggest
query={territoryAutocomplete.entityData?.query}
onSelect={territoryAutocomplete.handleSelect}
dropdownStyle={territoryAutocomplete.entityData?.style}
>{({ onKeyDown: territorySuggestOnKeyDown, resetSuggestions: resetTerritorySuggestions }) =>
children({
userSuggestOnKeyDown,
territorySuggestOnKeyDown,
resetUserSuggestions,
resetTerritorySuggestions
})}
</TerritorySuggest>
)}
</UserSuggest>
)
}
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
const [tab, setTab] = useState('write')
const [, meta, helpers] = useField(props)
@ -317,8 +151,10 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
const [updateUploadFees] = useLazyQuery(gql`
query uploadFees($s3Keys: [Int]!) {
uploadFees(s3Keys: $s3Keys) {
totalFees
nUnpaid
uploadFees
bytes24h
}
}`, {
fetchPolicy: 'no-cache',
@ -327,15 +163,13 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
console.error(err)
},
onCompleted: ({ uploadFees }) => {
const { uploadFees: feePerUpload, nUnpaid } = uploadFees
const totalFees = feePerUpload * nUnpaid
merge({
uploadFees: {
term: `+ ${numWithUnits(feePerUpload, { abbreviate: false })} x ${nUnpaid}`,
term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`,
label: 'upload fee',
op: '+',
modifier: cost => cost + totalFees,
omit: !totalFees
modifier: cost => cost + uploadFees.totalFees,
omit: !uploadFees.totalFees
}
})
}
@ -364,12 +198,18 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
}
}, [innerRef, selectionRange.start, selectionRange.end])
const { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({
meta,
helpers,
innerRef,
setSelectionRange
})
const [mention, setMention] = useState()
const insertMention = useCallback((name) => {
if (mention?.start === undefined || mention?.end === undefined) return
const { start, end } = mention
setMention(undefined)
const first = `${meta?.value.substring(0, start)}@${name}`
const second = meta?.value.substring(end)
const updatedValue = `${first}${second}`
helpers.setValue(updatedValue)
setSelectionRange({ start: first.length, end: first.length })
innerRef.current.focus()
}, [mention, meta?.value, helpers?.setValue])
const uploadFeesUpdate = useDebounceCallback(
(text) => {
@ -379,9 +219,86 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
const onChangeInner = useCallback((formik, e) => {
if (onChange) onChange(formik, e)
uploadFeesUpdate(e.target.value)
handleTextChange(e)
}, [onChange, uploadFeesUpdate, handleTextChange])
// check for mention editing
const { value, selectionStart } = e.target
uploadFeesUpdate(value)
if (!value || selectionStart === undefined) {
setMention(undefined)
return
}
let priorSpace = -1
for (let i = selectionStart - 1; i >= 0; i--) {
if (/[^\w@]/.test(value[i])) {
priorSpace = i
break
}
}
let nextSpace = value.length
for (let i = selectionStart; i <= value.length; i++) {
if (/[^\w]/.test(value[i])) {
nextSpace = i
break
}
}
const currentSegment = value.substring(priorSpace + 1, nextSpace)
// set the query to the current character segment and note where it appears
if (/^@\w*$/.test(currentSegment)) {
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
setMention({
query: currentSegment,
start: priorSpace + 1,
end: nextSpace,
style: {
position: 'absolute',
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
left: `${left}px`
}
})
} else {
setMention(undefined)
}
}, [onChange, setMention, uploadFeesUpdate])
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
return (e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (metaOrCtrl) {
if (e.key === 'k') {
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
e.preventDefault()
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'b') {
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
e.preventDefault()
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'i') {
// some browsers might use CTRL+I to do something else so prevent that behavior too
e.preventDefault()
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'u') {
// some browsers might use CTRL+U to do something else so prevent that behavior too
e.preventDefault()
imageUploadRef.current?.click()
}
if (e.key === 'Tab' && e.altKey) {
e.preventDefault()
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
}
if (!metaOrCtrl) {
userSuggestOnKeyDown(e)
}
if (onKeyDown) onKeyDown(e)
}
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown])
const onPaste = useCallback((event) => {
const items = event.clipboardData.items
@ -425,44 +342,6 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
setDragStyle(null)
}, [setDragStyle])
const onKeyDownInner = useCallback((userSuggestOnKeyDown, territorySuggestOnKeyDown) => {
return (e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
// Handle markdown shortcuts first
if (metaOrCtrl) {
if (e.key === 'k') {
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
e.preventDefault()
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'b') {
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
e.preventDefault()
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'i') {
// some browsers might use CTRL+I to do something else so prevent that behavior too
e.preventDefault()
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'u') {
// some browsers might use CTRL+U to do something else so prevent that behavior too
e.preventDefault()
imageUploadRef.current?.click()
}
if (e.key === 'Tab' && e.altKey) {
e.preventDefault()
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
} else {
handleKeyDown(e, userSuggestOnKeyDown, territorySuggestOnKeyDown)
}
if (onKeyDown) onKeyDown(e)
}
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown, handleKeyDown, imageUploadRef])
return (
<FormGroup label={label} className={groupClassName}>
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
@ -529,25 +408,24 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
</span>
</Nav>
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
<DualAutocompleteWrapper
userAutocomplete={userAutocomplete}
territoryAutocomplete={territoryAutocomplete}
>
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => (
<InputInner
innerRef={innerRef}
{...props}
onChange={onChangeInner}
onKeyDown={onKeyDownInner(userSuggestOnKeyDown, territorySuggestOnKeyDown)}
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onPaste={onPaste}
className={dragStyle === 'over' ? styles.dragOver : ''}
/>
)}
</DualAutocompleteWrapper>
<UserSuggest
query={mention?.query}
onSelect={insertMention}
dropdownStyle={mention?.style}
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions }) => (
<InputInner
innerRef={innerRef}
{...props}
onChange={onChangeInner}
onKeyDown={onKeyDownInner(userSuggestOnKeyDown)}
onBlur={() => setTimeout(resetSuggestions, 500)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onPaste={onPaste}
className={dragStyle === 'over' ? styles.dragOver : ''}
/>)}
</UserSuggest>
</div>
{tab !== 'write' &&
<div className='form-group'>
@ -609,7 +487,7 @@ function FormGroup ({ className, label, children }) {
function InputInner ({
prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue,
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, hideError,
AppendColumn, ...props
...props
}) {
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
const formik = noForm ? null : useFormikContext()
@ -687,43 +565,38 @@ function InputInner ({
return (
<>
<Row>
<Col>
<InputGroup hasValidation className={inputGroupClassName}>
{prepend}
<BootstrapForm.Control
ref={innerRef}
{...field}
{...props}
onKeyDown={onKeyDownInner}
onChange={onChangeInner}
onBlur={onBlurInner}
isInvalid={!hideError && invalid} // if hideError is true, handle error showing separately
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
/>
{(isClient && clear && field.value && !props.readOnly) &&
<Button
variant={null}
onClick={(e) => {
helpers.setValue('')
if (storageKey) {
window.localStorage.removeItem(storageKey)
}
if (onChange) {
onChange(formik, { target: { value: '' } })
}
}}
className={`${styles.clearButton} ${styles.appendButton} ${invalid ? styles.isInvalid : ''}`}
><CloseIcon className='fill-grey' height={20} width={20} />
</Button>}
{append}
<BootstrapForm.Control.Feedback type='invalid'>
{meta.touched && meta.error}
</BootstrapForm.Control.Feedback>
</InputGroup>
</Col>
{AppendColumn && <AppendColumn className={meta.touched && meta.error ? 'invisible' : ''} />}
</Row>
<InputGroup hasValidation className={inputGroupClassName}>
{prepend}
<BootstrapForm.Control
ref={innerRef}
{...field}
{...props}
onKeyDown={onKeyDownInner}
onChange={onChangeInner}
onBlur={onBlurInner}
isInvalid={!hideError && invalid} // if hideError is true, handle error showing separately
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
/>
{(isClient && clear && field.value && !props.readOnly) &&
<Button
variant={null}
onClick={(e) => {
helpers.setValue('')
if (storageKey) {
window.localStorage.removeItem(storageKey)
}
if (onChange) {
onChange(formik, { target: { value: '' } })
}
}}
className={`${styles.clearButton} ${styles.appendButton} ${invalid ? styles.isInvalid : ''}`}
><CloseIcon className='fill-grey' height={20} width={20} />
</Button>}
{append}
<BootstrapForm.Control.Feedback type='invalid'>
{meta.touched && meta.error}
</BootstrapForm.Control.Feedback>
</InputGroup>
{hint && (
<BootstrapForm.Text>
{hint}
@ -744,34 +617,34 @@ function InputInner ({
}
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
export function BaseSuggest ({
query, onSelect, dropdownStyle,
transformItem = item => item, selectWithTab = true, filterItems = () => true,
getSuggestionsQuery, queryName, itemsField,
children
export function UserSuggest ({
query, onSelect, dropdownStyle, children,
transformUser = user => user, selectWithTab = true, filterUsers = () => true
}) {
const [getSuggestions] = useLazyQuery(getSuggestionsQuery, {
const [getSuggestions] = useLazyQuery(USER_SUGGESTIONS, {
onCompleted: data => {
query !== undefined && setSuggestions({
array: data[itemsField]
.filter((...args) => filterItems(query, ...args))
.map(transformItem),
array: data.userSuggestions
.filter((...args) => filterUsers(query, ...args))
.map(transformUser),
index: 0
})
}
})
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), [])
useEffect(() => {
if (query !== undefined) {
// remove the leading character and any trailing spaces
const q = query?.replace(/^[@ ~]+|[ ]+$/g, '').replace(/@[^\s]*$/, '').replace(/~[^\s]*$/, '')
// remove both the leading @ and any @domain after nym
const q = query?.replace(/^[@ ]+|[ ]+$/g, '').replace(/@[^\s]*$/, '')
getSuggestions({ variables: { q, limit: 5 } })
} else {
resetSuggestions()
}
}, [query, resetSuggestions, getSuggestions])
const onKeyDown = useCallback(e => {
switch (e.code) {
case 'ArrowUp':
@ -816,6 +689,7 @@ export function BaseSuggest ({
break
}
}, [onSelect, resetSuggestions, suggestions])
return (
<>
{children?.({ onKeyDown, resetSuggestions })}
@ -838,17 +712,17 @@ export function BaseSuggest ({
)
}
function BaseInputSuggest ({
label, groupClassName, transformItem, filterItems,
selectWithTab, onChange, transformQuery, SuggestComponent, prefixRegex, ...props
export function InputUserSuggest ({
label, groupClassName, transformUser, filterUsers,
selectWithTab, onChange, transformQuery, ...props
}) {
const [ovalue, setOValue] = useState()
const [query, setQuery] = useState()
return (
<FormGroup label={label} className={groupClassName}>
<SuggestComponent
transformItem={transformItem}
filterItems={filterItems}
<UserSuggest
transformUser={transformUser}
filterUsers={filterUsers}
selectWithTab={selectWithTab}
onSelect={(v) => {
// HACK ... ovalue does not trigger onChange
@ -863,85 +737,19 @@ function BaseInputSuggest ({
autoComplete='off'
onChange={(formik, e) => {
onChange && onChange(formik, e)
if (e.target.value === ovalue) {
// we don't need to set the ovalue or query if the value is the same
return
}
setOValue(e.target.value)
setQuery(e.target.value.replace(prefixRegex, ''))
setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, ''))
}}
overrideValue={ovalue}
onKeyDown={onKeyDown}
onBlur={() => setTimeout(resetSuggestions, 500)}
/>
)}
</SuggestComponent>
</UserSuggest>
</FormGroup>
)
}
export function InputUserSuggest ({
transformUser, filterUsers, ...props
}) {
return (
<BaseInputSuggest
transformItem={transformUser}
filterItems={filterUsers}
SuggestComponent={UserSuggest}
prefixRegex={/^[@ ]+|[ ]+$/g}
{...props}
/>
)
}
export function InputTerritorySuggest ({
transformSub, filterSubs, ...props
}) {
return (
<BaseInputSuggest
transformItem={transformSub}
filterItems={filterSubs}
SuggestComponent={TerritorySuggest}
prefixRegex={/^[~ ]+|[ ]+$/g}
{...props}
/>
)
}
function UserSuggest ({
transformUser = user => user, filterUsers = () => true,
children, ...props
}) {
return (
<BaseSuggest
transformItem={transformUser}
filterItems={filterUsers}
getSuggestionsQuery={USER_SUGGESTIONS}
itemsField='userSuggestions'
{...props}
>
{children}
</BaseSuggest>
)
}
function TerritorySuggest ({
transformSub = sub => sub, filterSubs = () => true,
children, ...props
}) {
return (
<BaseSuggest
transformItem={transformSub}
filterItems={filterSubs}
getSuggestionsQuery={SUB_SUGGESTIONS}
itemsField='subSuggestions'
{...props}
>
{children}
</BaseSuggest>
)
}
export function Input ({ label, groupClassName, under, ...props }) {
return (
<FormGroup label={label} className={groupClassName}>
@ -957,38 +765,31 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
<FieldArray name={name} hasValidation>
{({ form, ...fieldArrayHelpers }) => {
const options = form.values[name]
return (
<>
{options?.map((_, i) => {
const AppendColumn = ({ className }) => (
<Col className={`d-flex ps-0 ${className}`} xs='auto'>
{options.length - 1 === i && options.length !== max
// onMouseDown is used to prevent the blur event on text inputs from overriding the click event
? <AddIcon className='fill-grey align-self-center justify-self-center pointer' onMouseDown={() => fieldArrayHelpers.push(emptyItem)} />
// filler div for col alignment across rows
: <div style={{ width: '24px', height: '24px' }} />}
</Col>
)
return (
<div key={i}>
<Row className='mb-2'>
<Col>
{children
? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined, AppendColumn })
: <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} AppendColumn={AppendColumn} />}
</Col>
{options.length - 1 === i &&
<>
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
{form.touched[name] && typeof form.errors[name] === 'string' &&
<div className='invalid-feedback d-block'>{form.errors[name]}</div>}
</>}
</Row>
</div>
)
})}
{options?.map((_, i) => (
<div key={i}>
<Row className='mb-2'>
<Col>
{children
? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined })
: <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} />}
</Col>
<Col className='d-flex ps-0' xs='auto'>
{options.length - 1 === i && options.length !== max
? <AddIcon className='fill-grey align-self-center justify-self-center pointer' onClick={() => fieldArrayHelpers.push(emptyItem)} />
// filler div for col alignment across rows
: <div style={{ width: '24px', height: '24px' }} />}
</Col>
{options.length - 1 === i &&
<>
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
{form.touched[name] && typeof form.errors[name] === 'string' &&
<div className='invalid-feedback d-block'>{form.errors[name]}</div>}
</>}
</Row>
</div>
))}
</>
)
}}
@ -1077,14 +878,15 @@ export function Form ({
})
}, [storageKeyPrefix])
const onSubmitInner = useCallback(async (values, ...args) => {
const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => {
const variables = { amount, ...values }
if (requireSession && !me) {
throw new SessionRequiredError()
}
try {
if (onSubmit) {
await onSubmit(values, ...args)
await onSubmit(variables, ...args)
}
} catch (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 }) {
const showModal = useShowModal()
const toaster = useToast()
@ -1362,10 +1191,8 @@ function PasswordScanner ({ onScan, text }) {
<Scanner
formats={['qr_code']}
onScan={([{ rawValue: result }]) => {
if (result) {
onScan(result)
onClose()
}
onScan(result)
onClose()
}}
styles={{
video: {
@ -1380,7 +1207,6 @@ function PasswordScanner ({ onScan, text }) {
}
onClose()
}}
components={{ audio: false }}
/>
)}
</div>
@ -1407,12 +1233,12 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, value: ini
{copy && (
<CopyButton icon value={field?.value} />
)}
{qr && (
<PasswordScanner
text="Where'd you learn to square dance?"
onScan={v => helpers.setValue(v)}
/>
)}
{qr && (readOnly
? <QrPassword value={field?.value} />
: <PasswordScanner
text="Where'd you learn to square dance?"
onScan={v => helpers.setValue(v)}
/>)}
{append}
</>
)

View File

@ -109,4 +109,4 @@
padding-top: 1px;
background-color: var(--bs-body-bg);
z-index: 1000;
}
}

View File

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

View File

@ -6,9 +6,9 @@ import { CompactLongCountdown } from './countdown'
import PayerData from './payer-data'
import Bolt11Info from './bolt11-info'
import { useQuery } from '@apollo/client'
import { INVOICE } from '@/fragments/invoice'
import { INVOICE } from '@/fragments/wallet'
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 Item from './item'
import { CommentFlat } from './comment'

View File

@ -6,22 +6,19 @@ import { useMe } from './me'
import UpBolt from '@/svgs/bolt.svg'
import { amountSchema, boostSchema } from '@/lib/validate'
import { useToast } from './toast'
import { useLightning } from './lightning'
import { nextTip, defaultTipIncludingRandom } from './upvote'
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { usePaidMutation } from './use-paid-mutation'
import { ACT_MUTATION } from '@/fragments/paidAction'
import { meAnonSats } from '@/lib/apollo'
import { BoostItemInput } from './adv-post-form'
import { useHasSendWallet } from '@/wallets/client/hooks'
import { useAnimation } from '@/components/animation'
import { useSendWallets } from '@/wallets/index'
const defaultTips = [100, 1000, 10_000, 100_000]
const Tips = ({ setOValue }) => {
const customTips = getCustomTips()
const defaultNoCustom = defaultTips.filter(d => !customTips.includes(d))
const tips = [...customTips, ...defaultNoCustom].slice(0, 7).sort((a, b) => a - b)
const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
return tips.map((num, i) =>
<Button
size='sm'
@ -40,7 +37,11 @@ const Tips = ({ setOValue }) => {
const getCustomTips = () => JSON.parse(window.localStorage.getItem('custom-tips')) || []
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))
}
@ -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 }) {
const inputRef = useRef(null)
const { me } = useMe()
const hasSendWallet = useHasSendWallet()
const wallets = useSendWallets()
const [oValue, setOValue] = useState()
useEffect(() => {
@ -96,7 +97,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
}, [onClose, item.id])
const actor = useAct()
const animate = useAnimation()
const strike = useLightning()
const onSubmit = useCallback(async ({ amount }) => {
if (abortSignal && zapUndoTrigger({ me, amount })) {
@ -111,12 +112,12 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
}
const onPaid = () => {
animate()
strike()
onClose?.()
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) {
onPaid()
}
@ -126,7 +127,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
id: item.id,
sats: Number(amount),
act,
hasSendWallet
hasSendWallet: wallets.length > 0
},
optimisticResponse: me
? {
@ -143,7 +144,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
})
if (error) throw error
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'
? <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,
// we need to extract the result/invoice from the response
const getPaidActionResult = data => Object.values(data)[0]
const hasSendWallet = useHasSendWallet()
const wallets = useSendWallets()
const [act] = usePaidMutation(query, {
waitFor: inv =>
// if we have attached wallets, we might be paying a wrapped invoice in which case we need to make sure
// we don't prematurely consider the payment as successful (important for receiver fallbacks)
hasSendWallet
wallets.length > 0
? inv?.actionState === 'PAID'
: inv?.satsReceived > 0,
...options,
@ -298,9 +299,9 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
}
export function useZap () {
const hasSendWallet = useHasSendWallet()
const wallets = useSendWallets()
const act = useAct()
const animate = useAnimation()
const strike = useLightning()
const toaster = useToast()
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
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 } } }
try {
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
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
} catch (error) {
if (error instanceof ActCanceledError) {
@ -327,7 +328,7 @@ export function useZap () {
// but right now this toast is noisy for optimistic zaps
console.error(error)
}
}, [act, toaster, animate, hasSendWallet])
}, [act, toaster, strike, wallets.length])
}
export class ActCanceledError extends Error {

View File

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

View File

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

View File

@ -13,9 +13,8 @@ import { MEDIA_URL } from '@/lib/constants'
import { abbrNum } from '@/lib/format'
import { Badge } from 'react-bootstrap'
import SubPopover from './sub-popover'
import { PaymentInfo } from './item-info'
export default function ItemJob ({ item, toc, rank, children, disableRetry, setDisableRetry }) {
export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = string().email().isValidSync(item.url)
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'>
edit
</Link>
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
</>)}
</div>
</div>

View File

@ -29,7 +29,7 @@ import { useShowModal } from './modal'
import { BoostHelp } from './adv-post-form'
function onItemClick (e, router, item) {
const viewedAt = commentsViewedAt(item.id)
const viewedAt = commentsViewedAt(item)
if (viewedAt) {
e.preventDefault()
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()
return (
<Container>
<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'>
{text || 'Login'} with Lightning
</h3>
<div className='fw-bold text-muted pb-4'>This is the most private way to use Stacker News. Just open your Lightning wallet and scan the QR code.</div>
<Row className='w-100 text-muted'>
<Col className='ps-0 mb-4' md={md} lg={lg}>
<Col className='ps-0 mb-4' md>
<AccordianItem
header={`Which wallets can I use to ${(text || 'Login').toLowerCase()}?`}
body={
@ -92,7 +92,7 @@ function LightningExplainer ({ text, children, backButton, md = 12, lg = 6 }) {
}
/>
</Col>
<Col md={md} lg={lg} className='mx-auto' style={{ maxWidth: '300px' }}>
<Col md className='mx-auto' style={{ maxWidth: '300px' }}>
{children}
</Col>
</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 (
<LightningExplainer text={text} backButton={backButton} md={md} lg={lg}>
<LightningExplainer text={text}>
<LightningAuth callbackUrl={callbackUrl} multiAuth={multiAuth} />
</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
*/
strike = () => {
this.setState(state => {
return {
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
}
})
const should = window.localStorage.getItem('lnAnimate') || 'yes'
if (should === 'yes') {
this.setState(state => {
return {
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
}
})
return true
}
return false
}
unstrike = (index) => {

View File

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

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

View File

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

View File

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

View File

@ -2,12 +2,11 @@ import { useState } from 'react'
import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap'
import { MEDIA_URL } from '@/lib/constants'
import Link from 'next/link'
import { Indicator, LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
import { LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
import AnonIcon from '@/svgs/spy-fill.svg'
import styles from './footer.module.css'
import canvasStyles from './offcanvas.module.css'
import classNames from 'classnames'
import { useWalletIndicator } from '@/wallets/client/hooks'
export default function OffCanvas ({ me, dropNavKey }) {
const [show, setShow] = useState(false)
@ -26,9 +25,6 @@ export default function OffCanvas ({ me, dropNavKey }) {
)
: <span className='text-muted pointer'><AnonIcon onClick={onClick} width='22' height='22' /></span>
const profileIndicator = me && !me.bioId
const walletIndicator = useWalletIndicator()
return (
<>
<MeImage onClick={handleShow} />
@ -54,17 +50,17 @@ export default function OffCanvas ({ me, dropNavKey }) {
<Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}>
profile
{profileIndicator && <Indicator />}
{me && !me.bioId &&
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
</div>}
</Dropdown.Item>
</Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
</Link>
<Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'>
wallets
{walletIndicator && <Indicator />}
</Dropdown.Item>
<Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
</Link>
<Link href='/credits' passHref legacyBehavior>
<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 === 'WithdrawlPaid' && <WithdrawlPaid n={n} />) ||
(type === 'Referral' && <Referral n={n} />) ||
(type === 'CowboyHat' && <CowboyHat n={n} />) ||
(['NewHorse', 'LostHorse'].includes(type) && <Horse n={n} />) ||
(['NewGun', 'LostGun'].includes(type) && <Gun n={n} />) ||
(type === 'Streak' && <Streak n={n} />) ||
(type === 'Votification' && <Votification n={n} />) ||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
(type === 'Mention' && <Mention n={n} />) ||
@ -114,14 +112,12 @@ function NoteHeader ({ color, children, big }) {
function NoteItem ({ item, ...props }) {
return (
<div>
{item.isJob
? <ItemJob item={item} {...props} />
: item.title
? <Item item={item} itemClassName='pt-0' {...props} />
: (
<RootProvider root={item.root}>
<Comment item={item} noReply includeParent clickToContext {...props} />
</RootProvider>)}
{item.title
? <Item item={item} itemClassName='pt-0' {...props} />
: (
<RootProvider root={item.root}>
<Comment item={item} noReply includeParent clickToContext {...props} />
</RootProvider>)}
</div>
)
}
@ -167,7 +163,7 @@ const defaultOnClick = n => {
if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` }
if (type === 'Referral') return { href: '/referrals/month' }
if (type === 'ReferralReward') return { href: '/referrals/month' }
if (['CowboyHat', 'NewHorse', 'LostHorse', 'NewGun', 'LostGun'].includes(type)) return {}
if (type === 'Streak') return {}
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
if (!n.item) return {}
@ -176,64 +172,30 @@ const defaultOnClick = n => {
return itemLink(n.item)
}
function blurb (n) {
const type = n.__typename === 'CowboyHat'
? 'COWBOY_HAT'
: (n.__typename.includes('Horse') ? 'HORSE' : 'GUN')
const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length)
const lost = n.days || n.__typename.includes('Lost')
return lost ? LOST_BLURBS[type][index] : FOUND_BLURBS[type][index]
}
function Streak ({ n }) {
function blurb (n) {
const type = n.type ?? 'COWBOY_HAT'
const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length)
if (n.days) {
return `After ${numWithUnits(n.days, {
abbreviate: false,
unitSingular: 'day',
unitPlural: 'days'
})}, ` + LOST_BLURBS[type][index]
}
function CowboyHat ({ n }) {
const Icon = n.days ? BaldIcon : CowboyHatIcon
let body = ''
if (n.days) {
body = `After ${numWithUnits(n.days, {
abbreviate: false,
unitSingular: 'day',
unitPlural: 'days'
})}, `
return FOUND_BLURBS[type][index]
}
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 (
<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'>{body}</span>
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div>
</div>
)
}
function Horse ({ n }) {
const found = n.__typename.includes('New')
const Icon = found ? HorseIcon : SaddleIcon
return (
<div className='d-flex'>
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
<div className='ms-1 p-1'>
<span className='fw-bold'>you {found ? 'found a' : 'lost your'} horse</span>
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div>
</div>
)
}
function Gun ({ n }) {
const found = n.__typename.includes('New')
const Icon = found ? GunIcon : HolsterIcon
return (
<div className='d-flex'>
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
<div className='ms-1 p-1'>
<span className='fw-bold'>you {found ? 'found a' : 'lost your'} gun</span>
<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>
@ -738,7 +700,7 @@ function Reminder ({ n }) {
return (
<>
<NoteHeader color='info'>
you requested this reminder
you asked to be reminded of this {n.item.title ? 'post' : 'comment'}
</NoteHeader>
<NoteItem item={n.item} />
</>

View File

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

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

View File

@ -110,7 +110,7 @@ export function PostForm ({ type, sub, children }) {
noForm
size='medium'
sub={sub?.name}
info={sub && <TerritoryInfo sub={sub} includeLink />}
info={sub && <TerritoryInfo sub={sub} />}
hint={sub?.moderated && 'this territory is moderated'}
/>
<div>
@ -176,7 +176,7 @@ export default function Post ({ sub }) {
className='d-flex'
size='medium'
label='territory'
info={sub && <TerritoryInfo sub={sub} includeLink />}
info={sub && <TerritoryInfo sub={sub} />}
hint={sub?.moderated && 'this territory is moderated'}
/>}
</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 useItemSubmit from './use-item-submit'
import gql from 'graphql-tag'
import { updateAncestorsCommentCount } from '@/lib/comments'
export default forwardRef(function Reply ({
item,
@ -83,7 +82,17 @@ export default forwardRef(function Reply ({
const ancestors = item.path.split('.')
// 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
// 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 styles from './search.module.css'
import SearchIcon from '@/svgs/search-line.svg'
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import {
Form,
Input,
Select,
DatePicker,
SubmitButton,
useDualAutocomplete,
DualAutocompleteWrapper
} from './form'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Form, Input, Select, DatePicker, SubmitButton } from './form'
import { useRouter } from 'next/router'
import { whenToFrom } from '@/lib/time'
import { useMe } from './me'
import { useField } from 'formik'
export default function Search ({ sub }) {
const router = useRouter()
const [q, setQ] = useState(router.query.q || '')
const inputRef = useRef(null)
const { me } = useMe()
useEffect(() => {
inputRef.current?.focus()
}, [])
const search = async values => {
let prefix = ''
if (sub) {
@ -67,13 +63,18 @@ export default function Search ({ sub }) {
onSubmit={values => search({ ...values })}
>
<div className={`${styles.active} mb-3`}>
<SearchInput
<Input
name='q'
required
autoFocus
groupClassName='me-3 mb-0 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}>
<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 { Workbox } from 'workbox-window'
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 ServiceWorkerContext = createContext()
// message types for communication between app and service worker
export const DELETE_SUBSCRIPTION = 'DELETE_SUBSCRIPTION'
export const STORE_SUBSCRIPTION = 'STORE_SUBSCRIPTION'
export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS'
export const MESSAGE_PORT = 'MESSAGE_PORT' // message to exchange message channel on which service worker will send messages back to app
export const ACTION_PORT = 'ACTION_PORT' // message to exchange action channel on which service worker will send actions back to app
export const SYNC_SUBSCRIPTION = 'SYNC_SUBSCRIPTION' // trigger onPushSubscriptionChange event in service worker manually
export const RESUBSCRIBE = 'RESUBSCRIBE' // trigger resubscribing to push notifications (sw -> app)
export const DELETE_SUBSCRIPTION = 'DELETE_SUBSCRIPTION' // delete subscription in IndexedDB (app -> sw)
export const STORE_SUBSCRIPTION = 'STORE_SUBSCRIPTION' // store subscription in IndexedDB (app -> sw)
export const STORE_OS = 'STORE_OS' // store OS in service worker
export const ServiceWorkerProvider = ({ children }) => {
const [registration, setRegistration] = useState(null)
@ -34,12 +38,13 @@ export const ServiceWorkerProvider = ({ children }) => {
`)
const [deletePushSubscription] = useMutation(
gql`
mutation deletePushSubscription($endpoint: String!) {
deletePushSubscription(endpoint: $endpoint) {
id
mutation deletePushSubscription($endpoint: String!) {
deletePushSubscription(endpoint: $endpoint) {
id
}
}
}
`)
`)
const logger = useServiceWorkerLogger()
// I am not entirely sure if this is needed since at least in Brave,
// using `registration.pushManager.subscribe` also prompts the user.
@ -72,6 +77,7 @@ export const ServiceWorkerProvider = ({ children }) => {
// see https://stackoverflow.com/a/69624651
let pushSubscription = await registration.pushManager.subscribe(subscribeOptions)
const { endpoint } = pushSubscription
logger.info('subscribed to push notifications', { endpoint })
// convert keys from ArrayBuffer to string
pushSubscription = JSON.parse(JSON.stringify(pushSubscription))
// Send subscription to service worker to save it so we can use it later during `pushsubscriptionchange`
@ -80,8 +86,7 @@ export const ServiceWorkerProvider = ({ children }) => {
action: STORE_SUBSCRIPTION,
subscription: pushSubscription
})
requestPersistentStorage()
logger.info('sent STORE_SUBSCRIPTION to service worker', { endpoint })
// send subscription to server
const variables = {
endpoint,
@ -89,21 +94,34 @@ export const ServiceWorkerProvider = ({ children }) => {
auth: pushSubscription.keys.auth
}
await savePushSubscription({ variables })
logger.info('sent push subscription to server', { endpoint })
}
const unsubscribeFromPushNotifications = async (subscription) => {
await subscription.unsubscribe()
const { endpoint } = subscription
logger.info('unsubscribed from push notifications', { endpoint })
await deletePushSubscription({ variables: { endpoint } })
// also delete push subscription in IndexedDB so we can tell if the user disabled push subscriptions
// or we lost the push subscription due to a bug
navigator.serviceWorker.controller.postMessage({ action: DELETE_SUBSCRIPTION })
logger.info('deleted push subscription from server', { endpoint })
}
const togglePushSubscription = useCallback(async () => {
const pushSubscription = await registration.pushManager.getSubscription()
if (pushSubscription) {
return 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(() => {
@ -115,15 +133,37 @@ export const ServiceWorkerProvider = ({ children }) => {
setPermission({ notification: 'Notification' in window ? window.Notification.permission : 'denied' })
if (!('serviceWorker' in navigator)) {
logger.info('device does not support service worker')
return
}
const wb = new Workbox('/sw.js', { scope: '/' })
wb.register().then(registration => {
logger.info('service worker registration successful')
setRegistration(registration)
})
}, [])
useEffect(() => {
// wait until successful registration
if (!registration) return
// setup channel between app and service worker
const channel = new MessageChannel()
navigator?.serviceWorker?.controller?.postMessage({ action: ACTION_PORT }, [channel.port2])
channel.port1.onmessage = (event) => {
if (event.data.action === RESUBSCRIBE && permission.notification === 'granted') {
return subscribeToPushNotifications()
}
}
// since (a lot of) browsers don't support the pushsubscriptionchange event,
// we sync with server manually by checking on every page reload if the push subscription changed.
// see https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
navigator?.serviceWorker?.controller?.postMessage?.({ action: STORE_OS, os: detectOS() })
logger.info('sent STORE_OS to service worker: ', detectOS())
navigator?.serviceWorker?.controller?.postMessage?.({ action: SYNC_SUBSCRIPTION })
logger.info('sent SYNC_SUBSCRIPTION to service worker')
}, [registration, permission.notification])
const contextValue = useMemo(() => ({
registration,
support,
@ -139,10 +179,6 @@ export const ServiceWorkerProvider = ({ children }) => {
)
}
export function clearNotifications () {
return navigator.serviceWorker?.controller?.postMessage({ action: CLEAR_NOTIFICATIONS })
}
export function useServiceWorker () {
return useContext(ServiceWorkerContext)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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\nhttps://stacker.news/items/${item.id}`
content += `\n\noriginally posted at https://stacker.news/items/${item.id}`
return content.trim()
}

View File

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

View File

@ -1,180 +1,300 @@
import { useMe } from '@/components/me'
import { useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
const VERSION = 2
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])
export function getDbName (userId, name) {
return `app:storage:${userId ?? ''}${name ? `:${name}` : ''}`
}
async function _open (dbName, version = 1) {
return await new Promise((resolve, reject) => {
if (typeof window.indexedDB === 'undefined') {
return reject(new IndexedDBOpenError('IndexedDB unavailable'))
const DEFAULT_OPTIONS = { keyPath: 'id', autoIncrement: true }
const DEFAULT_INDICES = []
const DEFAULT_VERSION = 1
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)
request.onupgradeneeded = (event) => {
try {
const db = event.target.result
if (!db.objectStoreNames.contains('vault')) db.createObjectStore('vault')
if (db.objectStoreNames.contains('wallet_logs')) db.deleteObjectStore('wallet_logs')
} catch (error) {
reject(new IndexedDBOpenError(`upgrade failed: ${error?.message}`))
return () => {
isMounted = false
if (db) {
db.close()
}
}
}, [dbName, storeName, version, indices, options, handleError, processQueue])
request.onerror = (event) => {
reject(new IndexedDBOpenError(request.error?.message))
const queueOperation = useCallback((operation) => {
if (notSupported) {
return Promise.reject(new Error('IndexedDB is not supported'))
}
if (error) {
return Promise.reject(new Error('Database error: ' + error.message))
}
request.onsuccess = (event) => {
const db = request.result
resolve(db)
}
})
return new Promise((resolve, reject) => {
const wrappedOperation = (db) => {
try {
const result = operation(db)
resolve(result)
} catch (error) {
reject(error)
}
}
operationQueue.current.push(wrappedOperation)
processQueue(db)
})
}, [processQueue, db, notSupported, error])
const add = useCallback((value) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.add(value)
request.onerror = () => reject(new Error('Error adding data'))
request.onsuccess = () => resolve(request.result)
})
})
}, [queueOperation, storeName])
const get = useCallback((key) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const request = store.get(key)
request.onerror = () => reject(new Error('Error getting data'))
request.onsuccess = () => resolve(request.result ? request.result : undefined)
})
})
}, [queueOperation, storeName])
const getAll = useCallback(() => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const request = store.getAll()
request.onerror = () => reject(new Error('Error getting all data'))
request.onsuccess = () => resolve(request.result)
})
})
}, [queueOperation, storeName])
const set = useCallback((key, value) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.put(value, key)
request.onerror = () => reject(new Error('Error setting data'))
request.onsuccess = () => resolve(request.result)
})
})
}, [queueOperation, storeName])
const remove = useCallback((key) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.delete(key)
request.onerror = () => reject(new Error('Error removing data'))
request.onsuccess = () => resolve()
})
})
}, [queueOperation, storeName])
const clear = useCallback((indexName = null, query = null) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
if (!query) {
// Clear all data if no query is provided
const request = store.clear()
request.onerror = () => reject(new Error('Error clearing all data'))
request.onsuccess = () => resolve()
} else {
// Clear data based on the query
const index = indexName ? store.index(indexName) : store
const request = index.openCursor(query)
let deletedCount = 0
request.onerror = () => reject(new Error('Error clearing data based on query'))
request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor) {
const deleteRequest = cursor.delete()
deleteRequest.onerror = () => reject(new Error('Error deleting item'))
deleteRequest.onsuccess = () => {
deletedCount++
cursor.continue()
}
} else {
resolve(deletedCount)
}
}
}
})
})
}, [queueOperation, storeName])
const getByIndex = useCallback((indexName, key) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const index = store.index(indexName)
const request = index.get(key)
request.onerror = () => reject(new Error('Error getting data by index'))
request.onsuccess = () => resolve(request.result)
})
})
}, [queueOperation, storeName])
const getAllByIndex = useCallback((indexName, query, direction = 'next', limit = Infinity) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const index = store.index(indexName)
const request = index.openCursor(query, direction)
const results = []
request.onerror = () => reject(new Error('Error getting data by index'))
request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor && results.length < limit) {
results.push(cursor.value)
cursor.continue()
} else {
resolve(results)
}
}
})
})
}, [queueOperation, storeName])
const getPage = useCallback((page = 1, pageSize = 10, indexName = null, query = null, direction = 'next') => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const target = indexName ? store.index(indexName) : store
const request = target.openCursor(query, direction)
const results = []
let skipped = 0
let hasMore = false
request.onerror = () => reject(new Error('Error getting page'))
request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor) {
if (skipped < (page - 1) * pageSize) {
skipped++
cursor.continue()
} else if (results.length < pageSize) {
results.push(cursor.value)
cursor.continue()
} else {
hasMore = true
}
}
if (hasMore || !cursor) {
const countRequest = target.count()
countRequest.onsuccess = () => {
resolve({
data: results,
total: countRequest.result,
hasMore
})
}
countRequest.onerror = () => reject(new Error('Error counting items'))
}
}
})
})
}, [queueOperation, storeName])
return { add, get, getAll, set, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported }
}
async function _set (db, storeName, key, value) {
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'
}
}
export default useIndexedDB

View File

@ -1,8 +1,8 @@
import { useApolloClient, useMutation } from '@apollo/client'
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 { INVOICE, CANCEL_INVOICE } from '@/fragments/invoice'
import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet'
export default function useInvoice () {
const client = useApolloClient()

View File

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

View File

@ -1,9 +1,9 @@
import { useCallback } from 'react'
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 useInvoice from '@/components/use-invoice'
import { sendPayment as weblnSendPayment } from '@/wallets/client/protocols/webln'
import { sendPayment } from '@/wallets/webln/client'
export default function useQrPayment () {
const invoice = useInvoice()
@ -19,7 +19,7 @@ export default function useQrPayment () {
) => {
// if anon user and webln is available, try to pay with webln
if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) {
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) => {
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 { gql, useApolloClient, useMutation } from '@apollo/client'
import styles from './user-header.module.css'
import navStyles from '@/styles/nav.module.css'
import { useMe } from './me'
import { NAME_MUTATION } from '@/fragments/users'
import { QRCodeSVG } from 'qrcode.react'
@ -29,11 +28,9 @@ import { hexToBech32 } from '@/lib/nostr'
import NostrIcon from '@/svgs/nostr.svg'
import GithubIcon from '@/svgs/github-fill.svg'
import TwitterIcon from '@/svgs/twitter-fill.svg'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
import { UNKNOWN_LINK_REL, MEDIA_URL } from '@/lib/constants'
import ItemPopover from './item-popover'
const MEDIA_URL = process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}`
export default function UserHeader ({ user }) {
const router = useRouter()
@ -45,7 +42,7 @@ export default function UserHeader ({ user }) {
<>
<HeaderHeader user={user} />
<Nav
className={navStyles.nav}
className={styles.nav}
activeKey={activeKey}
>
<Nav.Item>

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