Compare commits
No commits in common. "dfe0c4ad231422d7c9783ed85a59e5fc88578544" and "15bd1c3fc5a23767c6aa2d455df7218bce83d6fa" have entirely different histories.
dfe0c4ad23
...
15bd1c3fc5
32
.github/workflows/extend-awards.yml
vendored
32
.github/workflows/extend-awards.yml
vendored
@ -1,32 +0,0 @@
|
|||||||
name: extend-awards
|
|
||||||
run-name: Extending awards
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [ closed ]
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
jobs:
|
|
||||||
if_merged:
|
|
||||||
if: |
|
|
||||||
github.event_name == 'pull_request' &&
|
|
||||||
github.event.action == 'closed' &&
|
|
||||||
github.event.pull_request.merged == true &&
|
|
||||||
github.event.pull_request.head.ref != 'extend-awards/patch'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.13'
|
|
||||||
- run: pip install requests
|
|
||||||
- run: python extend-awards.py
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GITHUB_CONTEXT: ${{ toJson(github) }}
|
|
||||||
- uses: peter-evans/create-pull-request@v7
|
|
||||||
with:
|
|
||||||
add-paths: awards.csv
|
|
||||||
branch: extend-awards/patch
|
|
||||||
commit-message: Extending awards.csv
|
|
||||||
title: Extending awards.csv
|
|
||||||
body: A PR was merged that solves an issue and awards.csv should be extended.
|
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -61,7 +61,4 @@ scripts/nwc-keys.json
|
|||||||
docker/lnbits/data
|
docker/lnbits/data
|
||||||
|
|
||||||
# lndk
|
# lndk
|
||||||
!docker/lndk/tls-*.pem
|
!docker/lndk/tls-*.pem
|
||||||
|
|
||||||
# nostr link extract
|
|
||||||
scripts/nostr-link-extract.config.json
|
|
@ -31,7 +31,6 @@ Go to [localhost:3000](http://localhost:3000).
|
|||||||
- ssh: `git clone git@github.com:stackernews/stacker.news.git`
|
- ssh: `git clone git@github.com:stackernews/stacker.news.git`
|
||||||
- https: `git clone https://github.com/stackernews/stacker.news.git`
|
- https: `git clone https://github.com/stackernews/stacker.news.git`
|
||||||
- Install [docker](https://docs.docker.com/compose/install/)
|
- Install [docker](https://docs.docker.com/compose/install/)
|
||||||
- If you're running MacOS or Windows, I ***highly recommend*** using [OrbStack](https://orbstack.dev/) instead of Docker Desktop
|
|
||||||
- Please make sure that at least 10 GB of free space is available, otherwise you may encounter issues while setting up the development environment.
|
- Please make sure that at least 10 GB of free space is available, otherwise you may encounter issues while setting up the development environment.
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
@ -3,7 +3,7 @@ import { datePivot } from '@/lib/time'
|
|||||||
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||||
import { createHmac } from '@/api/resolvers/wallet'
|
import { createHmac } from '@/api/resolvers/wallet'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { createWrappedInvoice, createUserInvoice } from '@/wallets/server'
|
import { createWrappedInvoice, createInvoice as createUserInvoice } from '@/wallets/server'
|
||||||
import { assertBelowMaxPendingInvoices, assertBelowMaxPendingDirectPayments } from './lib/assert'
|
import { assertBelowMaxPendingInvoices, assertBelowMaxPendingDirectPayments } from './lib/assert'
|
||||||
|
|
||||||
import * as ITEM_CREATE from './itemCreate'
|
import * as ITEM_CREATE from './itemCreate'
|
||||||
@ -264,51 +264,42 @@ async function performDirectAction (actionType, args, incomingContext) {
|
|||||||
throw new NonInvoiceablePeerError()
|
throw new NonInvoiceablePeerError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let invoiceObject
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await assertBelowMaxPendingDirectPayments(userId, incomingContext)
|
await assertBelowMaxPendingDirectPayments(userId, incomingContext)
|
||||||
|
|
||||||
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
|
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
|
||||||
|
invoiceObject = await createUserInvoice(userId, {
|
||||||
for await (const { invoice, logger, wallet } of createUserInvoice(userId, {
|
|
||||||
msats: cost,
|
msats: cost,
|
||||||
description,
|
description,
|
||||||
expiry: INVOICE_EXPIRE_SECS
|
expiry: INVOICE_EXPIRE_SECS
|
||||||
}, { models, lnd })) {
|
}, { models, lnd })
|
||||||
let hash
|
|
||||||
try {
|
|
||||||
hash = parsePaymentRequest({ request: invoice }).id
|
|
||||||
} catch (e) {
|
|
||||||
console.error('failed to parse invoice', e)
|
|
||||||
logger?.error('failed to parse invoice: ' + e.message, { bolt11: invoice })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return {
|
|
||||||
invoice: await models.directPayment.create({
|
|
||||||
data: {
|
|
||||||
comment,
|
|
||||||
lud18Data,
|
|
||||||
desc: noteStr,
|
|
||||||
bolt11: invoice,
|
|
||||||
msats: cost,
|
|
||||||
hash,
|
|
||||||
walletId: wallet.id,
|
|
||||||
receiverId: userId
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
paymentMethod: 'DIRECT'
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('failed to create direct payment', e)
|
|
||||||
logger?.error('failed to create direct payment: ' + e.message, { bolt11: invoice })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('failed to create user invoice', e)
|
console.error('failed to create outside invoice', e)
|
||||||
|
throw new NonInvoiceablePeerError()
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new NonInvoiceablePeerError()
|
const { invoice, wallet } = invoiceObject
|
||||||
|
const hash = parsePaymentRequest({ request: invoice }).id
|
||||||
|
|
||||||
|
const payment = await models.directPayment.create({
|
||||||
|
data: {
|
||||||
|
comment,
|
||||||
|
lud18Data,
|
||||||
|
desc: noteStr,
|
||||||
|
bolt11: invoice,
|
||||||
|
msats: cost,
|
||||||
|
hash,
|
||||||
|
walletId: wallet.id,
|
||||||
|
receiverId: userId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
invoice: payment,
|
||||||
|
paymentMethod: 'DIRECT'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function retryPaidAction (actionType, args, incomingContext) {
|
export async function retryPaidAction (actionType, args, incomingContext) {
|
||||||
@ -428,7 +419,7 @@ async function createSNInvoice (actionType, args, context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createDbInvoice (actionType, args, context) {
|
async function createDbInvoice (actionType, args, context) {
|
||||||
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context
|
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, predecessorId } = context
|
||||||
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs
|
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs
|
||||||
|
|
||||||
const db = tx ?? models
|
const db = tx ?? models
|
||||||
@ -454,7 +445,6 @@ async function createDbInvoice (actionType, args, context) {
|
|||||||
actionArgs: args,
|
actionArgs: args,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
actionId,
|
actionId,
|
||||||
paymentAttempt,
|
|
||||||
predecessorId
|
predecessorId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -121,39 +121,6 @@ export default {
|
|||||||
FROM ${viewGroup(range, 'stacking_growth')}
|
FROM ${viewGroup(range, 'stacking_growth')}
|
||||||
GROUP BY time
|
GROUP BY time
|
||||||
ORDER BY time ASC`, ...range)
|
ORDER BY time ASC`, ...range)
|
||||||
},
|
|
||||||
itemGrowthSubs: async (parent, { when, to, from, sub }, { models }) => {
|
|
||||||
const range = whenRange(when, from, to)
|
|
||||||
|
|
||||||
const subExists = await models.sub.findUnique({ where: { name: sub } })
|
|
||||||
if (!subExists) throw new Error('Sub not found')
|
|
||||||
|
|
||||||
return await models.$queryRawUnsafe(`
|
|
||||||
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time, json_build_array(
|
|
||||||
json_build_object('name', 'posts', 'value', coalesce(sum(posts),0)),
|
|
||||||
json_build_object('name', 'comments', 'value', coalesce(sum(comments),0))
|
|
||||||
) AS data
|
|
||||||
FROM ${viewGroup(range, 'sub_stats')}
|
|
||||||
WHERE sub_name = $3
|
|
||||||
GROUP BY time
|
|
||||||
ORDER BY time ASC`, ...range, sub)
|
|
||||||
},
|
|
||||||
revenueGrowthSubs: async (parent, { when, to, from, sub }, { models }) => {
|
|
||||||
const range = whenRange(when, from, to)
|
|
||||||
|
|
||||||
const subExists = await models.sub.findUnique({ where: { name: sub } })
|
|
||||||
if (!subExists) throw new Error('Sub not found')
|
|
||||||
|
|
||||||
return await models.$queryRawUnsafe(`
|
|
||||||
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time, json_build_array(
|
|
||||||
json_build_object('name', 'revenue', 'value', coalesce(sum(msats_revenue/1000),0)),
|
|
||||||
json_build_object('name', 'stacking', 'value', coalesce(sum(msats_stacked/1000),0)),
|
|
||||||
json_build_object('name', 'spending', 'value', coalesce(sum(msats_spent/1000),0))
|
|
||||||
) AS data
|
|
||||||
FROM ${viewGroup(range, 'sub_stats')}
|
|
||||||
WHERE sub_name = $3
|
|
||||||
GROUP BY time
|
|
||||||
ORDER BY time ASC`, ...range, sub)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,6 @@ import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
|
|||||||
import { replyToSubscription } from '@/lib/webPush'
|
import { replyToSubscription } from '@/lib/webPush'
|
||||||
import { getSub } from './sub'
|
import { getSub } from './sub'
|
||||||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||||
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
@ -346,25 +345,11 @@ export default {
|
|||||||
)
|
)
|
||||||
|
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT "Invoice".id::text,
|
`(SELECT "Invoice".id::text, "Invoice"."updated_at" AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type
|
||||||
CASE
|
|
||||||
WHEN
|
|
||||||
"Invoice"."paymentAttempt" < ${WALLET_MAX_RETRIES}
|
|
||||||
AND "Invoice"."userCancel" = false
|
|
||||||
AND "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
|
|
||||||
THEN "Invoice"."cancelledAt" + interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
|
|
||||||
ELSE "Invoice"."updated_at"
|
|
||||||
END AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type
|
|
||||||
FROM "Invoice"
|
FROM "Invoice"
|
||||||
WHERE "Invoice"."userId" = $1
|
WHERE "Invoice"."userId" = $1
|
||||||
AND "Invoice"."updated_at" < $2
|
AND "Invoice"."updated_at" < $2
|
||||||
AND "Invoice"."actionState" = 'FAILED'
|
AND "Invoice"."actionState" = 'FAILED'
|
||||||
AND (
|
|
||||||
-- this is the inverse of the filter for automated retries
|
|
||||||
"Invoice"."paymentAttempt" >= ${WALLET_MAX_RETRIES}
|
|
||||||
OR "Invoice"."userCancel" = true
|
|
||||||
OR "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
|
|
||||||
)
|
|
||||||
AND (
|
AND (
|
||||||
"Invoice"."actionType" = 'ITEM_CREATE' OR
|
"Invoice"."actionType" = 'ITEM_CREATE' OR
|
||||||
"Invoice"."actionType" = 'ZAP' OR
|
"Invoice"."actionType" = 'ZAP' OR
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { retryPaidAction } from '../paidAction'
|
import { retryPaidAction } from '../paidAction'
|
||||||
import { USER_ID, WALLET_MAX_RETRIES, WALLET_RETRY_TIMEOUT_MS } from '@/lib/constants'
|
import { USER_ID } from '@/lib/constants'
|
||||||
|
|
||||||
function paidActionType (actionType) {
|
function paidActionType (actionType) {
|
||||||
switch (actionType) {
|
switch (actionType) {
|
||||||
@ -50,32 +50,24 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
retryPaidAction: async (parent, { invoiceId, newAttempt }, { models, me, lnd }) => {
|
retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new Error('You must be logged in')
|
throw new Error('You must be logged in')
|
||||||
}
|
}
|
||||||
|
|
||||||
// make sure only one client at a time can retry by acquiring a lock that expires
|
const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me.id } })
|
||||||
const [invoice] = await models.$queryRaw`
|
|
||||||
UPDATE "Invoice"
|
|
||||||
SET "retryPendingSince" = now()
|
|
||||||
WHERE
|
|
||||||
id = ${invoiceId} AND
|
|
||||||
"userId" = ${me.id} AND
|
|
||||||
"actionState" = 'FAILED' AND
|
|
||||||
("retryPendingSince" IS NULL OR "retryPendingSince" < now() - ${`${WALLET_RETRY_TIMEOUT_MS} milliseconds`}::interval)
|
|
||||||
RETURNING *`
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new Error('Invoice not found or retry pending')
|
throw new Error('Invoice not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
// do we want to retry a payment from the beginning with all sender and receiver wallets?
|
if (invoice.actionState !== 'FAILED') {
|
||||||
const paymentAttempt = newAttempt ? invoice.paymentAttempt + 1 : invoice.paymentAttempt
|
if (invoice.actionState === 'PAID') {
|
||||||
if (paymentAttempt > WALLET_MAX_RETRIES) {
|
throw new Error('Invoice is already paid')
|
||||||
throw new Error('Payment has been retried too many times')
|
}
|
||||||
|
throw new Error(`Invoice is not in failed state: ${invoice.actionState}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await retryPaidAction(invoice.actionType, { invoice }, { paymentAttempt, models, me, lnd })
|
const result = await retryPaidAction(invoice.actionType, { invoice }, { models, me, lnd })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
@ -4,9 +4,9 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
|||||||
import { msatsToSats } from '@/lib/format'
|
import { msatsToSats } from '@/lib/format'
|
||||||
import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate'
|
import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate'
|
||||||
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item'
|
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item'
|
||||||
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES, WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
|
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES } from '@/lib/constants'
|
||||||
import { viewGroup } from './growth'
|
import { viewGroup } from './growth'
|
||||||
import { datePivot, timeUnitForRange, whenRange } from '@/lib/time'
|
import { timeUnitForRange, whenRange } from '@/lib/time'
|
||||||
import assertApiKeyNotPermitted from './apiKey'
|
import assertApiKeyNotPermitted from './apiKey'
|
||||||
import { hashEmail } from '@/lib/crypto'
|
import { hashEmail } from '@/lib/crypto'
|
||||||
import { isMuted } from '@/lib/user'
|
import { isMuted } from '@/lib/user'
|
||||||
@ -543,17 +543,7 @@ export default {
|
|||||||
actionType: {
|
actionType: {
|
||||||
in: INVOICE_ACTION_NOTIFICATION_TYPES
|
in: INVOICE_ACTION_NOTIFICATION_TYPES
|
||||||
},
|
},
|
||||||
actionState: 'FAILED',
|
actionState: 'FAILED'
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
paymentAttempt: {
|
|
||||||
gte: WALLET_MAX_RETRIES
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
userCancel: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -562,31 +552,6 @@ export default {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoiceActionFailed2 = await models.invoice.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: me.id,
|
|
||||||
updatedAt: {
|
|
||||||
gt: datePivot(lastChecked, { milliseconds: -WALLET_RETRY_BEFORE_MS })
|
|
||||||
},
|
|
||||||
actionType: {
|
|
||||||
in: INVOICE_ACTION_NOTIFICATION_TYPES
|
|
||||||
},
|
|
||||||
actionState: 'FAILED',
|
|
||||||
paymentAttempt: {
|
|
||||||
lt: WALLET_MAX_RETRIES
|
|
||||||
},
|
|
||||||
userCancel: false,
|
|
||||||
cancelledAt: {
|
|
||||||
lte: datePivot(new Date(), { milliseconds: -WALLET_RETRY_BEFORE_MS })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (invoiceActionFailed2) {
|
|
||||||
foundNotes()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// update checkedNotesAt to prevent rechecking same time period
|
// update checkedNotesAt to prevent rechecking same time period
|
||||||
models.user.update({
|
models.user.update({
|
||||||
where: { id: me.id },
|
where: { id: me.id },
|
||||||
|
@ -9,10 +9,7 @@ import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib
|
|||||||
import {
|
import {
|
||||||
USER_ID, INVOICE_RETENTION_DAYS,
|
USER_ID, INVOICE_RETENTION_DAYS,
|
||||||
PAID_ACTION_PAYMENT_METHODS,
|
PAID_ACTION_PAYMENT_METHODS,
|
||||||
WALLET_CREATE_INVOICE_TIMEOUT_MS,
|
WALLET_CREATE_INVOICE_TIMEOUT_MS
|
||||||
WALLET_RETRY_AFTER_MS,
|
|
||||||
WALLET_RETRY_BEFORE_MS,
|
|
||||||
WALLET_MAX_RETRIES
|
|
||||||
} from '@/lib/constants'
|
} from '@/lib/constants'
|
||||||
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
|
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
|
||||||
import assertGofacYourself from './ofac'
|
import assertGofacYourself from './ofac'
|
||||||
@ -459,21 +456,6 @@ const resolvers = {
|
|||||||
cursor: nextCursor,
|
cursor: nextCursor,
|
||||||
entries: logs
|
entries: logs
|
||||||
}
|
}
|
||||||
},
|
|
||||||
failedInvoices: async (parent, args, { me, models }) => {
|
|
||||||
if (!me) {
|
|
||||||
throw new GqlAuthenticationError()
|
|
||||||
}
|
|
||||||
return await models.$queryRaw`
|
|
||||||
SELECT * FROM "Invoice"
|
|
||||||
WHERE "userId" = ${me.id}
|
|
||||||
AND "actionState" = 'FAILED'
|
|
||||||
-- never retry if user has cancelled the invoice manually
|
|
||||||
AND "userCancel" = false
|
|
||||||
AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval
|
|
||||||
AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval
|
|
||||||
AND "paymentAttempt" < ${WALLET_MAX_RETRIES}
|
|
||||||
ORDER BY id DESC`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Wallet: {
|
Wallet: {
|
||||||
|
@ -13,8 +13,6 @@ export default gql`
|
|||||||
spenderGrowth(when: String, from: String, to: String): [TimeData!]!
|
spenderGrowth(when: String, from: String, to: String): [TimeData!]!
|
||||||
stackingGrowth(when: String, from: String, to: String): [TimeData!]!
|
stackingGrowth(when: String, from: String, to: String): [TimeData!]!
|
||||||
stackerGrowth(when: String, from: String, to: String): [TimeData!]!
|
stackerGrowth(when: String, from: String, to: String): [TimeData!]!
|
||||||
itemGrowthSubs(when: String, from: String, to: String, sub: String): [TimeData!]!
|
|
||||||
revenueGrowthSubs(when: String, from: String, to: String, sub: String): [TimeData!]!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimeData {
|
type TimeData {
|
||||||
|
@ -159,7 +159,7 @@ export default gql`
|
|||||||
remote: Boolean
|
remote: Boolean
|
||||||
sub: Sub
|
sub: Sub
|
||||||
subName: String
|
subName: String
|
||||||
status: String!
|
status: String
|
||||||
uploadId: Int
|
uploadId: Int
|
||||||
otsHash: String
|
otsHash: String
|
||||||
parentOtsHash: String
|
parentOtsHash: String
|
||||||
|
@ -7,7 +7,7 @@ extend type Query {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
retryPaidAction(invoiceId: Int!, newAttempt: Boolean): PaidAction!
|
retryPaidAction(invoiceId: Int!): PaidAction!
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PaymentMethod {
|
enum PaymentMethod {
|
||||||
|
@ -31,7 +31,7 @@ export default gql`
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Sub {
|
type Sub {
|
||||||
name: String!
|
name: ID!
|
||||||
createdAt: Date!
|
createdAt: Date!
|
||||||
userId: Int!
|
userId: Int!
|
||||||
user: User!
|
user: User!
|
||||||
|
@ -49,7 +49,7 @@ export default gql`
|
|||||||
type User {
|
type User {
|
||||||
id: ID!
|
id: ID!
|
||||||
createdAt: Date!
|
createdAt: Date!
|
||||||
name: String!
|
name: String
|
||||||
nitems(when: String, from: String, to: String): Int!
|
nitems(when: String, from: String, to: String): Int!
|
||||||
nposts(when: String, from: String, to: String): Int!
|
nposts(when: String, from: String, to: String): Int!
|
||||||
nterritories(when: String, from: String, to: String): Int!
|
nterritories(when: String, from: String, to: String): Int!
|
||||||
|
@ -72,7 +72,6 @@ const typeDefs = `
|
|||||||
wallet(id: ID!): Wallet
|
wallet(id: ID!): Wallet
|
||||||
walletByType(type: String!): Wallet
|
walletByType(type: String!): Wallet
|
||||||
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
|
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
|
||||||
failedInvoices: [Invoice!]!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
|
11
awards.csv
11
awards.csv
@ -174,14 +174,3 @@ Soxasora,pr,#1820,#1819,easy,,,1,90k,soxasora@blink.sv,2025-01-27
|
|||||||
SatsAllDay,issue,#1820,#1819,easy,,,1,9k,weareallsatoshi@getalby.com,2025-01-27
|
SatsAllDay,issue,#1820,#1819,easy,,,1,9k,weareallsatoshi@getalby.com,2025-01-27
|
||||||
Soxasora,pr,#1814,#1736,easy,,,,100k,soxasora@blink.sv,2025-01-27
|
Soxasora,pr,#1814,#1736,easy,,,,100k,soxasora@blink.sv,2025-01-27
|
||||||
jason-me,pr,#1857,,easy,,,,100k,rrbtc@vlt.ge,2025-02-08
|
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-SN,pr,#1928,#1924,good-first-issue,,,,20k,???,???
|
|
||||||
dtonon,issue,#1928,#1924,good-first-issue,,,,2k,???,???
|
|
||||||
ed-kung,pr,#1926,#1914,medium-hard,,,,500k,simplestacker@getalby.com,???
|
|
||||||
ed-kung,issue,#1926,#1914,medium-hard,,,,50k,simplestacker@getalby.com,???
|
|
||||||
ed-kung,pr,#1926,#1927,easy,,,,100k,simplestacker@getalby.com,???
|
|
||||||
ed-kung,issue,#1926,#1927,easy,,,,10k,simplestacker@getalby.com,???
|
|
||||||
ed-kung,issue,#1913,#1890,good-first-issue,,,,2k,simplestacker@getalby.com,???
|
|
||||||
Scroogey-SN,pr,#1930,#1167,good-first-issue,,,,20k,???,???
|
|
||||||
itsrealfake,issue,#1930,#1167,good-first-issue,,,,2k,???,???
|
|
||||||
|
|
@ -34,23 +34,20 @@ const setTheme = (dark) => {
|
|||||||
|
|
||||||
const listenForThemeChange = (onChange) => {
|
const listenForThemeChange = (onChange) => {
|
||||||
const mql = window.matchMedia(PREFER_DARK_QUERY)
|
const mql = window.matchMedia(PREFER_DARK_QUERY)
|
||||||
const onMqlChange = () => {
|
mql.onchange = mql => {
|
||||||
const { user, dark } = getTheme()
|
const { user, dark } = getTheme()
|
||||||
if (!user) {
|
if (!user) {
|
||||||
handleThemeChange(dark)
|
handleThemeChange(dark)
|
||||||
onChange({ user, dark })
|
onChange({ user, dark })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mql.addEventListener('change', onMqlChange)
|
window.onstorage = e => {
|
||||||
|
|
||||||
const onStorage = (e) => {
|
|
||||||
if (e.key === STORAGE_KEY) {
|
if (e.key === STORAGE_KEY) {
|
||||||
const dark = JSON.parse(e.newValue)
|
const dark = JSON.parse(e.newValue)
|
||||||
setTheme(dark)
|
setTheme(dark)
|
||||||
onChange({ user: true, dark })
|
onChange({ user: true, dark })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('storage', onStorage)
|
|
||||||
|
|
||||||
const root = window.document.documentElement
|
const root = window.document.documentElement
|
||||||
const observer = new window.MutationObserver(() => {
|
const observer = new window.MutationObserver(() => {
|
||||||
@ -59,11 +56,7 @@ const listenForThemeChange = (onChange) => {
|
|||||||
})
|
})
|
||||||
observer.observe(root, { attributes: true, attributeFilter: ['data-bs-theme'] })
|
observer.observe(root, { attributes: true, attributeFilter: ['data-bs-theme'] })
|
||||||
|
|
||||||
return () => {
|
return () => observer.disconnect()
|
||||||
observer.disconnect()
|
|
||||||
mql.removeEventListener('change', onMqlChange)
|
|
||||||
window.removeEventListener('storage', onStorage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useDarkMode () {
|
export default function useDarkMode () {
|
||||||
@ -72,7 +65,7 @@ export default function useDarkMode () {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { user, dark } = getTheme()
|
const { user, dark } = getTheme()
|
||||||
setDark({ user, dark })
|
setDark({ user, dark })
|
||||||
return listenForThemeChange(setDark)
|
listenForThemeChange(setDark)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return [dark?.dark, () => {
|
return [dark?.dark, () => {
|
||||||
|
@ -173,7 +173,7 @@ export default function Footer ({ links = true }) {
|
|||||||
<Rewards />
|
<Rewards />
|
||||||
</div>
|
</div>
|
||||||
<div className='mb-0' style={{ fontWeight: 500 }}>
|
<div className='mb-0' style={{ fontWeight: 500 }}>
|
||||||
<Link href='/stackers/all/day' className='nav-link p-0 p-0 d-inline-flex'>
|
<Link href='/stackers/day' className='nav-link p-0 p-0 d-inline-flex'>
|
||||||
analytics
|
analytics
|
||||||
</Link>
|
</Link>
|
||||||
<span className='mx-2 text-muted'> \ </span>
|
<span className='mx-2 text-muted'> \ </span>
|
||||||
|
@ -20,6 +20,7 @@ import TextareaAutosize from 'react-textarea-autosize'
|
|||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import textAreaCaret from 'textarea-caret'
|
import textAreaCaret from 'textarea-caret'
|
||||||
|
import ReactDatePicker from 'react-datepicker'
|
||||||
import 'react-datepicker/dist/react-datepicker.css'
|
import 'react-datepicker/dist/react-datepicker.css'
|
||||||
import useDebounceCallback, { debounce } from './use-debounce-callback'
|
import useDebounceCallback, { debounce } from './use-debounce-callback'
|
||||||
import { FileUpload } from './file-upload'
|
import { FileUpload } from './file-upload'
|
||||||
@ -37,10 +38,9 @@ import QrIcon from '@/svgs/qr-code-line.svg'
|
|||||||
import QrScanIcon from '@/svgs/qr-scan-line.svg'
|
import QrScanIcon from '@/svgs/qr-scan-line.svg'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { QRCodeSVG } from 'qrcode.react'
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
import dynamic from 'next/dynamic'
|
import { Scanner } from '@yudiel/react-qr-scanner'
|
||||||
import { qrImageSettings } from './qr'
|
import { qrImageSettings } from './qr'
|
||||||
import { useIsClient } from './use-client'
|
import { useIsClient } from './use-client'
|
||||||
import PageLoading from './page-loading'
|
|
||||||
|
|
||||||
export class SessionRequiredError extends Error {
|
export class SessionRequiredError extends Error {
|
||||||
constructor () {
|
constructor () {
|
||||||
@ -971,19 +971,6 @@ export function Select ({ label, items, info, groupClassName, onChange, noForm,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DatePickerSkeleton () {
|
|
||||||
return (
|
|
||||||
<div className='react-datepicker-wrapper'>
|
|
||||||
<input className='form-control clouds fade-out p-0 px-2 mb-0' />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ReactDatePicker = dynamic(() => import('react-datepicker').then(mod => mod.default), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <DatePickerSkeleton />
|
|
||||||
})
|
|
||||||
|
|
||||||
export function DatePicker ({ fromName, toName, noForm, onChange, when, from, to, className, ...props }) {
|
export function DatePicker ({ fromName, toName, noForm, onChange, when, from, to, className, ...props }) {
|
||||||
const formik = noForm ? null : useFormikContext()
|
const formik = noForm ? null : useFormikContext()
|
||||||
const [,, fromHelpers] = noForm ? [{}, {}, {}] : useField({ ...props, name: fromName })
|
const [,, fromHelpers] = noForm ? [{}, {}, {}] : useField({ ...props, name: fromName })
|
||||||
@ -1051,23 +1038,19 @@ export function DatePicker ({ fromName, toName, noForm, onChange, when, from, to
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ReactDatePicker
|
||||||
{ReactDatePicker && (
|
className={`form-control text-center ${className}`}
|
||||||
<ReactDatePicker
|
selectsRange
|
||||||
className={`form-control text-center ${className}`}
|
maxDate={new Date()}
|
||||||
selectsRange
|
minDate={new Date('2021-05-01')}
|
||||||
maxDate={new Date()}
|
{...props}
|
||||||
minDate={new Date('2021-05-01')}
|
selected={new Date(innerFrom)}
|
||||||
{...props}
|
startDate={new Date(innerFrom)}
|
||||||
selected={new Date(innerFrom)}
|
endDate={innerTo ? new Date(innerTo) : undefined}
|
||||||
startDate={new Date(innerFrom)}
|
dateFormat={dateFormat}
|
||||||
endDate={innerTo ? new Date(innerTo) : undefined}
|
onChangeRaw={onChangeRawHandler}
|
||||||
dateFormat={dateFormat}
|
onChange={innerOnChange}
|
||||||
onChangeRaw={onChangeRawHandler}
|
/>
|
||||||
onChange={innerOnChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1087,27 +1070,19 @@ export function DateTimeInput ({ label, groupClassName, name, ...props }) {
|
|||||||
|
|
||||||
function DateTimePicker ({ name, className, ...props }) {
|
function DateTimePicker ({ name, className, ...props }) {
|
||||||
const [field, , helpers] = useField({ ...props, name })
|
const [field, , helpers] = useField({ ...props, name })
|
||||||
const ReactDatePicker = dynamic(() => import('react-datepicker').then(mod => mod.default), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <span>loading date picker</span>
|
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ReactDatePicker
|
||||||
{ReactDatePicker && (
|
{...field}
|
||||||
<ReactDatePicker
|
{...props}
|
||||||
{...field}
|
showTimeSelect
|
||||||
{...props}
|
dateFormat='Pp'
|
||||||
showTimeSelect
|
className={`form-control ${className}`}
|
||||||
dateFormat='Pp'
|
selected={(field.value && new Date(field.value)) || null}
|
||||||
className={`form-control ${className}`}
|
value={(field.value && new Date(field.value)) || null}
|
||||||
selected={(field.value && new Date(field.value)) || null}
|
onChange={(val) => {
|
||||||
value={(field.value && new Date(field.value)) || null}
|
helpers.setValue(val)
|
||||||
onChange={(val) => {
|
}}
|
||||||
helpers.setValue(val)
|
/>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1174,10 +1149,6 @@ function QrPassword ({ value }) {
|
|||||||
function PasswordScanner ({ onScan, text }) {
|
function PasswordScanner ({ onScan, text }) {
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const Scanner = dynamic(() => import('@yudiel/react-qr-scanner').then(mod => mod.Scanner), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <PageLoading />
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputGroup.Text
|
<InputGroup.Text
|
||||||
@ -1187,28 +1158,26 @@ function PasswordScanner ({ onScan, text }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{text && <h5 className='line-height-md mb-4 text-center'>{text}</h5>}
|
{text && <h5 className='line-height-md mb-4 text-center'>{text}</h5>}
|
||||||
{Scanner && (
|
<Scanner
|
||||||
<Scanner
|
formats={['qr_code']}
|
||||||
formats={['qr_code']}
|
onScan={([{ rawValue: result }]) => {
|
||||||
onScan={([{ rawValue: result }]) => {
|
onScan(result)
|
||||||
onScan(result)
|
onClose()
|
||||||
onClose()
|
}}
|
||||||
}}
|
styles={{
|
||||||
styles={{
|
video: {
|
||||||
video: {
|
aspectRatio: '1 / 1'
|
||||||
aspectRatio: '1 / 1'
|
}
|
||||||
}
|
}}
|
||||||
}}
|
onError={(error) => {
|
||||||
onError={(error) => {
|
if (error instanceof DOMException) {
|
||||||
if (error instanceof DOMException) {
|
console.log(error)
|
||||||
console.log(error)
|
} else {
|
||||||
} else {
|
toaster.danger('qr scan: ' + error?.message || error?.toString?.())
|
||||||
toaster.danger('qr scan: ' + error?.message || error?.toString?.())
|
}
|
||||||
}
|
onClose()
|
||||||
onClose()
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -31,7 +31,6 @@
|
|||||||
.linkBoxParent input,
|
.linkBoxParent input,
|
||||||
.linkBoxParent iframe,
|
.linkBoxParent iframe,
|
||||||
.linkBoxParent video,
|
.linkBoxParent video,
|
||||||
.linkBoxParent pre,
|
|
||||||
.linkBoxParent img {
|
.linkBoxParent img {
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
}
|
}
|
@ -1,79 +0,0 @@
|
|||||||
import { useRouter } from 'next/router'
|
|
||||||
import { Select, DatePicker } from './form'
|
|
||||||
import { useSubs } from './sub-select'
|
|
||||||
import { WHENS } from '@/lib/constants'
|
|
||||||
import { whenToFrom } from '@/lib/time'
|
|
||||||
import styles from './sub-select.module.css'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
export function SubAnalyticsHeader ({ pathname = null }) {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const path = pathname || 'stackers'
|
|
||||||
|
|
||||||
const select = async values => {
|
|
||||||
const { sub, when, ...query } = values
|
|
||||||
|
|
||||||
if (when !== 'custom') { delete query.from; delete query.to }
|
|
||||||
if (query.from && !query.to) return
|
|
||||||
|
|
||||||
await router.push({
|
|
||||||
|
|
||||||
pathname: `/${path}/${sub}/${when}`,
|
|
||||||
query
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const when = router.query.when || 'day'
|
|
||||||
const sub = router.query.sub || 'all'
|
|
||||||
|
|
||||||
const subs = useSubs({ prependSubs: ['all'], sub, appendSubs: [], filterSubs: () => true })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='text-muted fw-bold my-0 d-flex align-items-center flex-wrap'>
|
|
||||||
<div className='text-muted fw-bold mb-2 d-flex align-items-center'>
|
|
||||||
stacker analytics in
|
|
||||||
<Select
|
|
||||||
groupClassName='mb-0 mx-2'
|
|
||||||
className={classNames(styles.subSelect, styles.subSelectSmall)}
|
|
||||||
name='sub'
|
|
||||||
size='sm'
|
|
||||||
items={subs}
|
|
||||||
value={sub}
|
|
||||||
noForm
|
|
||||||
onChange={(formik, e) => {
|
|
||||||
const range = when === 'custom' ? { from: router.query.from, to: router.query.to } : {}
|
|
||||||
select({ sub: e.target.value, when, ...range })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
for
|
|
||||||
<Select
|
|
||||||
groupClassName='mb-0 mx-2'
|
|
||||||
className='w-auto'
|
|
||||||
name='when'
|
|
||||||
size='sm'
|
|
||||||
items={WHENS}
|
|
||||||
value={when}
|
|
||||||
noForm
|
|
||||||
onChange={(formik, e) => {
|
|
||||||
const range = e.target.value === 'custom' ? { from: whenToFrom(when), to: Date.now() } : {}
|
|
||||||
select({ sub, when: e.target.value, ...range })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{when === 'custom' &&
|
|
||||||
<DatePicker
|
|
||||||
noForm
|
|
||||||
fromName='from'
|
|
||||||
toName='to'
|
|
||||||
className='p-0 px-2 mb-0'
|
|
||||||
onChange={(formik, [from, to], e) => {
|
|
||||||
select({ sub, when, from: from.getTime(), to: to.getTime() })
|
|
||||||
}}
|
|
||||||
from={router.query.from}
|
|
||||||
to={router.query.to}
|
|
||||||
when={when}
|
|
||||||
/>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,7 +1,8 @@
|
|||||||
import styles from './text.module.css'
|
import styles from './text.module.css'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import gfm from 'remark-gfm'
|
import gfm from 'remark-gfm'
|
||||||
import dynamic from 'next/dynamic'
|
import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||||
|
import atomDark from 'react-syntax-highlighter/dist/cjs/styles/prism/atom-dark'
|
||||||
import React, { useState, memo, useRef, useCallback, useMemo, useEffect } from 'react'
|
import React, { useState, memo, useRef, useCallback, useMemo, useEffect } from 'react'
|
||||||
import MediaOrLink from './media-or-link'
|
import MediaOrLink from './media-or-link'
|
||||||
import { IMGPROXY_URL_REGEXP, decodeProxyUrl } from '@/lib/url'
|
import { IMGPROXY_URL_REGEXP, decodeProxyUrl } from '@/lib/url'
|
||||||
@ -20,6 +21,7 @@ import rehypeSN from '@/lib/rehype-sn'
|
|||||||
import remarkUnicode from '@/lib/remark-unicode'
|
import remarkUnicode from '@/lib/remark-unicode'
|
||||||
import Embed from './embed'
|
import Embed from './embed'
|
||||||
import remarkMath from 'remark-math'
|
import remarkMath from 'remark-math'
|
||||||
|
import rehypeMathjax from 'rehype-mathjax'
|
||||||
|
|
||||||
const rehypeSNStyled = () => rehypeSN({
|
const rehypeSNStyled = () => rehypeSN({
|
||||||
stylers: [{
|
stylers: [{
|
||||||
@ -34,6 +36,7 @@ const rehypeSNStyled = () => rehypeSN({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const remarkPlugins = [gfm, remarkUnicode, [remarkMath, { singleDollarTextMath: false }]]
|
const remarkPlugins = [gfm, remarkUnicode, [remarkMath, { singleDollarTextMath: false }]]
|
||||||
|
const rehypePlugins = [rehypeSNStyled, rehypeMathjax]
|
||||||
|
|
||||||
export function SearchText ({ text }) {
|
export function SearchText ({ text }) {
|
||||||
return (
|
return (
|
||||||
@ -49,32 +52,16 @@ export function SearchText ({ text }) {
|
|||||||
|
|
||||||
// this is one of the slowest components to render
|
// this is one of the slowest components to render
|
||||||
export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) {
|
export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) {
|
||||||
// would the text overflow on the current screen size?
|
|
||||||
const [overflowing, setOverflowing] = useState(false)
|
const [overflowing, setOverflowing] = useState(false)
|
||||||
// should we show the full text?
|
const router = useRouter()
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
const containerRef = useRef(null)
|
const containerRef = useRef(null)
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const [mathJaxPlugin, setMathJaxPlugin] = useState(null)
|
|
||||||
|
|
||||||
// we only need mathjax if there's math content between $$ tags
|
|
||||||
useEffect(() => {
|
|
||||||
if (/\$\$(.|\n)+\$\$/g.test(children)) {
|
|
||||||
import('rehype-mathjax').then(mod => {
|
|
||||||
setMathJaxPlugin(() => mod.default)
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('error loading mathjax', err)
|
|
||||||
setMathJaxPlugin(null)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [children])
|
|
||||||
|
|
||||||
// if we are navigating to a hash, show the full text
|
// if we are navigating to a hash, show the full text
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShow(router.asPath.includes('#'))
|
setShow(router.asPath.includes('#') && !router.asPath.includes('#itemfn-'))
|
||||||
const handleRouteChange = (url, { shallow }) => {
|
const handleRouteChange = (url, { shallow }) => {
|
||||||
setShow(url.includes('#'))
|
setShow(url.includes('#') && !url.includes('#itemfn-'))
|
||||||
}
|
}
|
||||||
|
|
||||||
router.events.on('hashChangeStart', handleRouteChange)
|
router.events.on('hashChangeStart', handleRouteChange)
|
||||||
@ -147,12 +134,12 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child
|
|||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
components={components}
|
components={components}
|
||||||
remarkPlugins={remarkPlugins}
|
remarkPlugins={remarkPlugins}
|
||||||
rehypePlugins={[rehypeSNStyled, mathJaxPlugin].filter(Boolean)}
|
rehypePlugins={rehypePlugins}
|
||||||
remarkRehypeOptions={{ clobberPrefix: `itemfn-${itemId}-` }}
|
remarkRehypeOptions={{ clobberPrefix: `itemfn-${itemId}-` }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
), [components, remarkPlugins, mathJaxPlugin, children, itemId])
|
), [components, remarkPlugins, rehypePlugins, children, itemId])
|
||||||
|
|
||||||
const showOverflow = useCallback(() => setShow(true), [setShow])
|
const showOverflow = useCallback(() => setShow(true), [setShow])
|
||||||
|
|
||||||
@ -241,59 +228,18 @@ function Table ({ node, ...props }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// prevent layout shifting when the code block is loading
|
|
||||||
function CodeSkeleton ({ className, children, ...props }) {
|
|
||||||
return (
|
|
||||||
<div className='rounded' style={{ padding: '0.5em' }}>
|
|
||||||
<code className={`${className}`} {...props}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Code ({ node, inline, className, children, style, ...props }) {
|
function Code ({ node, inline, className, children, style, ...props }) {
|
||||||
const [ReactSyntaxHighlighter, setReactSyntaxHighlighter] = useState(null)
|
return inline
|
||||||
const [syntaxTheme, setSyntaxTheme] = useState(null)
|
? (
|
||||||
const language = className?.match(/language-(\w+)/)?.[1] || 'text'
|
|
||||||
|
|
||||||
const loadHighlighter = useCallback(() =>
|
|
||||||
Promise.all([
|
|
||||||
dynamic(() => import('react-syntax-highlighter').then(mod => mod.LightAsync), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <CodeSkeleton className={className} {...props}>{children}</CodeSkeleton>
|
|
||||||
}),
|
|
||||||
import('react-syntax-highlighter/dist/cjs/styles/hljs/atom-one-dark').then(mod => mod.default)
|
|
||||||
]), []
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!inline && language !== 'math') { // MathJax should handle math
|
|
||||||
// loading the syntax highlighter and theme only when needed
|
|
||||||
loadHighlighter().then(([highlighter, theme]) => {
|
|
||||||
setReactSyntaxHighlighter(() => highlighter)
|
|
||||||
setSyntaxTheme(() => theme)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [inline])
|
|
||||||
|
|
||||||
if (inline || !ReactSyntaxHighlighter) { // inline code doesn't have a border radius
|
|
||||||
return (
|
|
||||||
<code className={className} {...props}>
|
<code className={className} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
)
|
)
|
||||||
}
|
: (
|
||||||
|
<SyntaxHighlighter style={atomDark} language='text' PreTag='div' {...props}>
|
||||||
return (
|
{children}
|
||||||
<>
|
</SyntaxHighlighter>
|
||||||
{ReactSyntaxHighlighter && syntaxTheme && (
|
)
|
||||||
<ReactSyntaxHighlighter style={syntaxTheme} language={language} PreTag='div' customStyle={{ borderRadius: '0.3rem' }} {...props}>
|
|
||||||
{children}
|
|
||||||
</ReactSyntaxHighlighter>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...props }) {
|
function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...props }) {
|
||||||
|
@ -3,10 +3,10 @@ import { Select, DatePicker } from './form'
|
|||||||
import { WHENS } from '@/lib/constants'
|
import { WHENS } from '@/lib/constants'
|
||||||
import { whenToFrom } from '@/lib/time'
|
import { whenToFrom } from '@/lib/time'
|
||||||
|
|
||||||
export function UserAnalyticsHeader ({ pathname = null }) {
|
export function UsageHeader ({ pathname = null }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const path = pathname || 'satistics/graph'
|
const path = pathname || 'stackers'
|
||||||
|
|
||||||
const select = async values => {
|
const select = async values => {
|
||||||
const { when, ...query } = values
|
const { when, ...query } = values
|
@ -1,5 +1,5 @@
|
|||||||
import { useApolloClient, useMutation } from '@apollo/client'
|
import { useApolloClient, useMutation } from '@apollo/client'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors'
|
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors'
|
||||||
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
|
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
|
||||||
import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet'
|
import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet'
|
||||||
@ -42,9 +42,9 @@ export default function useInvoice () {
|
|||||||
return data.cancelInvoice
|
return data.cancelInvoice
|
||||||
}, [cancelInvoice])
|
}, [cancelInvoice])
|
||||||
|
|
||||||
const retry = useCallback(async ({ id, hash, hmac, newAttempt = false }, { update } = {}) => {
|
const retry = useCallback(async ({ id, hash, hmac }, { update }) => {
|
||||||
console.log('retrying invoice:', hash)
|
console.log('retrying invoice:', hash)
|
||||||
const { data, error } = await retryPaidAction({ variables: { invoiceId: Number(id), newAttempt }, update })
|
const { data, error } = await retryPaidAction({ variables: { invoiceId: Number(id) }, update })
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|
||||||
const newInvoice = data.retryPaidAction.invoice
|
const newInvoice = data.retryPaidAction.invoice
|
||||||
@ -53,5 +53,5 @@ export default function useInvoice () {
|
|||||||
return newInvoice
|
return newInvoice
|
||||||
}, [retryPaidAction])
|
}, [retryPaidAction])
|
||||||
|
|
||||||
return useMemo(() => ({ cancel, retry, isInvoice }), [cancel, retry, isInvoice])
|
return { cancel, retry, isInvoice }
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import Invoice from '@/components/invoice'
|
import Invoice from '@/components/invoice'
|
||||||
import { InvoiceCanceledError, InvoiceExpiredError, AnonWalletError } from '@/wallets/errors'
|
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
|
||||||
import { useShowModal } from '@/components/modal'
|
import { useShowModal } from '@/components/modal'
|
||||||
import useInvoice from '@/components/use-invoice'
|
import useInvoice from '@/components/use-invoice'
|
||||||
import { sendPayment } from '@/wallets/webln/client'
|
|
||||||
|
|
||||||
export default function useQrPayment () {
|
export default function useQrPayment () {
|
||||||
const invoice = useInvoice()
|
const invoice = useInvoice()
|
||||||
@ -17,10 +16,6 @@ export default function useQrPayment () {
|
|||||||
waitFor = inv => inv?.satsReceived > 0
|
waitFor = inv => inv?.satsReceived > 0
|
||||||
} = {}
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
// if anon user and webln is available, try to pay with webln
|
|
||||||
if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) {
|
|
||||||
sendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) })
|
|
||||||
}
|
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
let paid
|
let paid
|
||||||
const cancelAndReject = async (onClose) => {
|
const cancelAndReject = async (onClose) => {
|
||||||
|
@ -10,6 +10,4 @@ mz
|
|||||||
btcbagehot
|
btcbagehot
|
||||||
felipe
|
felipe
|
||||||
benalleng
|
benalleng
|
||||||
rblb
|
rblb
|
||||||
Scroogey
|
|
||||||
SimpleStacker
|
|
@ -1,54 +0,0 @@
|
|||||||
# Automatically extend awards.csv
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Whenever a pull request (PR) is merged in the [stacker.news](https://github.com/stackernews/stacker.news) repository, a [GitHub Action](https://docs.github.com/en/actions) is triggered:
|
|
||||||
|
|
||||||
If the merged PR solves an issue with [award tags](https://github.com/stackernews/stacker.news?tab=readme-ov-file#contributing),
|
|
||||||
the amounts due to the PR and issue authors are calculated and corresponding lines are added to the [awards.csv](https://github.com/stackernews/stacker.news/blob/master/awards.csv) file,
|
|
||||||
and a PR is opened for this change.
|
|
||||||
|
|
||||||
## Action
|
|
||||||
|
|
||||||
The action is defined in [.github/workflows/extend-awards.yml](.github/workflows/extend-awards.yml).
|
|
||||||
|
|
||||||
Filters on the event type and parameters ensure the action is [triggered only on merged PRs](https://stackoverflow.com/questions/60710209/trigger-github-actions-only-when-pr-is-merged).
|
|
||||||
|
|
||||||
The primary job consists of several steps:
|
|
||||||
- [checkout](https://github.com/actions/checkout) checks out the repository
|
|
||||||
- [setup-python](https://github.com/actions/setup-python) installs [Python](https://en.wikipedia.org/wiki/Python_(programming_language))
|
|
||||||
- [pip](https://en.wikipedia.org/wiki/Pip_%28package_manager%29) installs the [requests](https://docs.python-requests.org/en/latest/index.html) module
|
|
||||||
- a script (see below) is executed, which appends lines to [awards.csv](awards.csv) if needed
|
|
||||||
- [create-pull-request](https://github.com/peter-evans/create-pull-request) looks for modified files and creates (or updates) a PR
|
|
||||||
|
|
||||||
## Script
|
|
||||||
|
|
||||||
The script is [extend-awards.py](extend-awards.py).
|
|
||||||
|
|
||||||
The script extracts from the [environment](https://en.wikipedia.org/wiki/Environment_variable) an authentication token needed for the [GitHub REST API](https://docs.github.com/en/rest/about-the-rest-api/about-the-rest-api) and the [context](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs) containing the event details including the merged PR (formatted in [JSON](https://en.wikipedia.org/wiki/JSON)).
|
|
||||||
|
|
||||||
In the merged PR's title and body it searches for the first [GitHub issue URL](https://github.com/stackernews/stacker.news/issues/) or any number with a hash symbol (#) prefix, and takes this as the issue being solved by the PR.
|
|
||||||
|
|
||||||
Using the GitHub REST API it fetches the issue and analyzes its tags for difficulty and priority.
|
|
||||||
|
|
||||||
It fetches the issue's timeline and counts the number of reviews completed with status 'changes requested' to calculate the amount reduction.
|
|
||||||
|
|
||||||
It calculates the amounts due to the PR author and the issue author.
|
|
||||||
|
|
||||||
It reads the existing awards.csv file to suppress appending redundant lines (same user, PR, and issue) and fill known receive methods (same user).
|
|
||||||
|
|
||||||
Finally, it appends zero, one, or two lines to the awards.csv file.
|
|
||||||
|
|
||||||
## Diagnostics
|
|
||||||
|
|
||||||
In the GitHub web interface under 'Actions' each invokation of the action can be viewed, including environment and [output and errors](https://en.wikipedia.org/wiki/Standard_streams) of the script. First, the specific invokation is selected, then the job 'if_merged', then the step 'Run python extend-awards.py'. The environment is found by expanding the inner 'Run python extended-awards.py' on the first line.
|
|
||||||
|
|
||||||
The normal output includes details about the issue number found, the amount calculation, or the reason for not appending lines.
|
|
||||||
|
|
||||||
The error output may include a [Python traceback](https://realpython.com/python-traceback/) which helps to explain the error.
|
|
||||||
|
|
||||||
The environment contains in GITHUB_CONTEXT the event details, which may be required to understand the error.
|
|
||||||
|
|
||||||
## Security considerations
|
|
||||||
|
|
||||||
The create-pull-request step requires [workflow permissions](https://github.com/peter-evans/create-pull-request#workflow-permissions).
|
|
104
extend-awards.py
104
extend-awards.py
@ -1,104 +0,0 @@
|
|||||||
import json, os, re, requests
|
|
||||||
|
|
||||||
difficulties = {'good-first-issue':20000,'easy':100000,'medium':250000,'medium-hard':500000,'hard':1000000}
|
|
||||||
priorities = {'low':0.5,'medium':1.5,'high':2,'urgent':3}
|
|
||||||
ignored = ['huumn', 'ekzyis']
|
|
||||||
fn = 'awards.csv'
|
|
||||||
|
|
||||||
sess = requests.Session()
|
|
||||||
headers = {'Authorization':'Bearer %s' % os.getenv('GITHUB_TOKEN') }
|
|
||||||
awards = []
|
|
||||||
|
|
||||||
def getIssue(n):
|
|
||||||
url = 'https://api.github.com/repos/stackernews/stacker.news/issues/' + n
|
|
||||||
r = sess.get(url, headers=headers)
|
|
||||||
j = json.loads(r.text)
|
|
||||||
return j
|
|
||||||
|
|
||||||
def findIssueInPR(j):
|
|
||||||
p = re.compile('(#|https://github.com/stackernews/stacker.news/issues/)([0-9]+)')
|
|
||||||
for m in p.finditer(j['title']):
|
|
||||||
return m.group(2)
|
|
||||||
if not 'body' in j or j['body'] is None:
|
|
||||||
return
|
|
||||||
for s in j['body'].split('\n'):
|
|
||||||
for m in p.finditer(s):
|
|
||||||
return m.group(2)
|
|
||||||
|
|
||||||
def addAward(user, kind, pr, issue, difficulty, priority, count, amount):
|
|
||||||
if amount >= 1000000 and amount % 1000000 == 0:
|
|
||||||
amount = str(int(amount / 1000000)) + 'm'
|
|
||||||
elif amount >= 1000 and amount % 1000 == 0:
|
|
||||||
amount = str(int(amount / 1000)) + 'k'
|
|
||||||
for a in awards:
|
|
||||||
if a[0] == user and a[1] == kind and a[2] == pr:
|
|
||||||
print('found existing entry %s' % a)
|
|
||||||
if a[8] != amount:
|
|
||||||
print('warning: amount %s != %s' % (a[8], amount))
|
|
||||||
return
|
|
||||||
if count < 1:
|
|
||||||
count = ''
|
|
||||||
addr = '???'
|
|
||||||
for a in awards:
|
|
||||||
if a[0] == user and a[9] != '???':
|
|
||||||
addr = a[9]
|
|
||||||
print('adding %s,%s,%s,%s,%s,%s,%s,,%s,%s,???' % (user, kind, pr, issue, difficulty, priority, count, amount, addr))
|
|
||||||
with open(fn, 'a') as f:
|
|
||||||
print('%s,%s,%s,%s,%s,%s,%s,,%s,%s,???' % (user, kind, pr, issue, difficulty, priority, count, amount, addr), file=f)
|
|
||||||
|
|
||||||
def countReviews(pr):
|
|
||||||
url = 'https://api.github.com/repos/stackernews/stacker.news/issues/%s/timeline' % pr
|
|
||||||
r = sess.get(url, headers=headers)
|
|
||||||
j = json.loads(r.text)
|
|
||||||
count = 0
|
|
||||||
for e in j:
|
|
||||||
if e['event'] == 'reviewed' and e['state'] == 'changes_requested':
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
def checkPR(i):
|
|
||||||
pr = str(i['number'])
|
|
||||||
print('pr %s' % pr)
|
|
||||||
n = findIssueInPR(i)
|
|
||||||
if not n:
|
|
||||||
print('pr %s does not solve an issue' % pr)
|
|
||||||
return
|
|
||||||
print('solves issue %s' % n)
|
|
||||||
j = getIssue(n)
|
|
||||||
difficulty = ''
|
|
||||||
amount = 0
|
|
||||||
priority = ''
|
|
||||||
multiplier = 1
|
|
||||||
for l in j['labels']:
|
|
||||||
for d in difficulties:
|
|
||||||
if l['name'] == 'difficulty:' + d:
|
|
||||||
difficulty = d
|
|
||||||
amount = difficulties[d]
|
|
||||||
for p in priorities:
|
|
||||||
if l['name'] == 'priority:' + p:
|
|
||||||
priority = p
|
|
||||||
multiplier = priorities[p]
|
|
||||||
if amount * multiplier <= 0:
|
|
||||||
print('issue gives no award')
|
|
||||||
return
|
|
||||||
count = countReviews(pr)
|
|
||||||
if count >= 10:
|
|
||||||
print('too many reviews, no award')
|
|
||||||
return
|
|
||||||
if count > 0:
|
|
||||||
print('%d reviews, %d%% reduction' % (count, count * 10))
|
|
||||||
award = amount * multiplier * (10 - count) / 10
|
|
||||||
print('award is %d' % award)
|
|
||||||
if i['user']['login'] not in ignored:
|
|
||||||
addAward(i['user']['login'], 'pr', '#' + pr, '#' + n, difficulty, priority, count, award)
|
|
||||||
if j['user']['login'] not in ignored:
|
|
||||||
count = 0
|
|
||||||
addAward(j['user']['login'], 'issue', '#' + pr, '#' + n, difficulty, priority, count, int(award / 10))
|
|
||||||
|
|
||||||
with open(fn, 'r') as f:
|
|
||||||
for s in f:
|
|
||||||
s = s.split('\n')[0]
|
|
||||||
awards.append(s.split(','))
|
|
||||||
|
|
||||||
j = json.loads(os.getenv('GITHUB_CONTEXT'))
|
|
||||||
checkPR(j['event']['pull_request'])
|
|
@ -91,8 +91,8 @@ export const RETRY_PAID_ACTION = gql`
|
|||||||
${PAID_ACTION}
|
${PAID_ACTION}
|
||||||
${ITEM_PAID_ACTION_FIELDS}
|
${ITEM_PAID_ACTION_FIELDS}
|
||||||
${ITEM_ACT_PAID_ACTION_FIELDS}
|
${ITEM_ACT_PAID_ACTION_FIELDS}
|
||||||
mutation retryPaidAction($invoiceId: Int!, $newAttempt: Boolean) {
|
mutation retryPaidAction($invoiceId: Int!) {
|
||||||
retryPaidAction(invoiceId: $invoiceId, newAttempt: $newAttempt) {
|
retryPaidAction(invoiceId: $invoiceId) {
|
||||||
__typename
|
__typename
|
||||||
...PaidActionFields
|
...PaidActionFields
|
||||||
... on ItemPaidAction {
|
... on ItemPaidAction {
|
||||||
|
@ -231,12 +231,3 @@ export const CANCEL_INVOICE = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
export const FAILED_INVOICES = gql`
|
|
||||||
${INVOICE_FIELDS}
|
|
||||||
query FailedInvoices {
|
|
||||||
failedInvoices {
|
|
||||||
...InvoiceFields
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
@ -283,12 +283,6 @@ function getClient (uri) {
|
|||||||
facts: [...(existing?.facts || []), ...incoming.facts]
|
facts: [...(existing?.facts || []), ...incoming.facts]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
failedInvoices: {
|
|
||||||
keyArgs: [],
|
|
||||||
merge (existing, incoming) {
|
|
||||||
return incoming
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
// to be loaded from the server
|
// to be loaded from the server
|
||||||
export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
|
export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
|
||||||
export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
|
export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
|
||||||
export const RESERVED_SUB_NAMES = ['all', 'home']
|
|
||||||
|
|
||||||
export const PAID_ACTION_PAYMENT_METHODS = {
|
export const PAID_ACTION_PAYMENT_METHODS = {
|
||||||
FEE_CREDIT: 'FEE_CREDIT',
|
FEE_CREDIT: 'FEE_CREDIT',
|
||||||
@ -13,7 +12,7 @@ export const PAID_ACTION_PAYMENT_METHODS = {
|
|||||||
REWARD_SATS: 'REWARD_SATS'
|
REWARD_SATS: 'REWARD_SATS'
|
||||||
}
|
}
|
||||||
export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']
|
export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']
|
||||||
export const NOFOLLOW_LIMIT = 250
|
export const NOFOLLOW_LIMIT = 1000
|
||||||
export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener'
|
export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener'
|
||||||
export const UPLOAD_SIZE_MAX = 50 * 1024 * 1024
|
export const UPLOAD_SIZE_MAX = 50 * 1024 * 1024
|
||||||
export const UPLOAD_SIZE_MAX_AVATAR = 5 * 1024 * 1024
|
export const UPLOAD_SIZE_MAX_AVATAR = 5 * 1024 * 1024
|
||||||
@ -199,14 +198,4 @@ export const ZAP_UNDO_DELAY_MS = 5_000
|
|||||||
export const WALLET_SEND_PAYMENT_TIMEOUT_MS = 150_000
|
export const WALLET_SEND_PAYMENT_TIMEOUT_MS = 150_000
|
||||||
export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 45_000
|
export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 45_000
|
||||||
|
|
||||||
// interval between which failed invoices are returned to a client for automated retries.
|
|
||||||
// retry-after must be high enough such that intermediate failed invoices that will already
|
|
||||||
// be retried by the client due to sender or receiver fallbacks are not returned to the client.
|
|
||||||
export const WALLET_RETRY_AFTER_MS = 60_000 // 1 minute
|
|
||||||
export const WALLET_RETRY_BEFORE_MS = 3_600_000 // 1 hour
|
|
||||||
// we want to attempt a payment three times so we retry two times
|
|
||||||
export const WALLET_MAX_RETRIES = 2
|
|
||||||
// when a pending retry for an invoice should be considered expired and can be attempted again
|
|
||||||
export const WALLET_RETRY_TIMEOUT_MS = 60_000 // 1 minute
|
|
||||||
|
|
||||||
export const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
|
export const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
|
||||||
|
@ -59,10 +59,10 @@ export default function rehypeSN (options = {}) {
|
|||||||
if (node.properties.href.includes('#itemfn-')) {
|
if (node.properties.href.includes('#itemfn-')) {
|
||||||
node.tagName = 'footnote'
|
node.tagName = 'footnote'
|
||||||
} else {
|
} else {
|
||||||
const { itemId, commentId, linkText } = parseInternalLinks(node.properties.href)
|
const { itemId, linkText } = parseInternalLinks(node.properties.href)
|
||||||
if (itemId || commentId) {
|
if (itemId) {
|
||||||
node.tagName = 'item'
|
node.tagName = 'item'
|
||||||
node.properties.id = commentId || itemId
|
node.properties.id = itemId
|
||||||
if (node.properties.href === toString(node)) {
|
if (node.properties.href === toString(node)) {
|
||||||
node.children[0].value = linkText
|
node.children[0].value = linkText
|
||||||
}
|
}
|
||||||
|
@ -93,7 +93,7 @@ export function parseEmbedUrl (href) {
|
|||||||
const { hostname, pathname, searchParams } = new URL(href)
|
const { hostname, pathname, searchParams } = new URL(href)
|
||||||
|
|
||||||
// nostr prefixes: [npub1, nevent1, nprofile1, note1]
|
// nostr prefixes: [npub1, nevent1, nprofile1, note1]
|
||||||
const nostr = href.match(/\/(?<id>(?<type>npub1|nevent1|nprofile1|note1|naddr1)[02-9ac-hj-np-z]+)/)
|
const nostr = href.match(/(?<id>(?<type>npub1|nevent1|nprofile1|note1|naddr1)[02-9ac-hj-np-z]+)/)
|
||||||
if (nostr?.groups?.id) {
|
if (nostr?.groups?.id) {
|
||||||
let id = nostr.groups.id
|
let id = nostr.groups.id
|
||||||
if (nostr.groups.type === 'npub1') {
|
if (nostr.groups.type === 'npub1') {
|
||||||
|
@ -2,8 +2,7 @@ import { string, ValidationError, number, object, array, boolean, date } from '.
|
|||||||
import {
|
import {
|
||||||
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES,
|
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES,
|
||||||
MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES,
|
MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES,
|
||||||
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX,
|
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX
|
||||||
RESERVED_SUB_NAMES
|
|
||||||
} from './constants'
|
} from './constants'
|
||||||
import { SUPPORTED_CURRENCIES } from './currency'
|
import { SUPPORTED_CURRENCIES } from './currency'
|
||||||
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
|
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
|
||||||
@ -307,7 +306,7 @@ export function territorySchema (args) {
|
|||||||
const isArchived = sub => sub.status === 'STOPPED'
|
const isArchived = sub => sub.status === 'STOPPED'
|
||||||
const filter = sub => editing ? !isEdit(sub) : !isArchived(sub)
|
const filter = sub => editing ? !isEdit(sub) : !isArchived(sub)
|
||||||
const exists = await subExists(name, { ...args, filter })
|
const exists = await subExists(name, { ...args, filter })
|
||||||
return !exists & !RESERVED_SUB_NAMES.includes(name)
|
return !exists
|
||||||
},
|
},
|
||||||
message: 'taken'
|
message: 'taken'
|
||||||
}),
|
}),
|
||||||
|
@ -189,11 +189,6 @@ module.exports = withPlausibleProxy()({
|
|||||||
source: '/statistics',
|
source: '/statistics',
|
||||||
destination: '/satistics?inc=invoice,withdrawal',
|
destination: '/satistics?inc=invoice,withdrawal',
|
||||||
permanent: true
|
permanent: true
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/top/cowboys/:when',
|
|
||||||
destination: '/top/cowboys',
|
|
||||||
permanent: true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -22,7 +22,7 @@ export default function Email () {
|
|||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (callback.callbackUrl) params.set('callbackUrl', callback.callbackUrl)
|
if (callback.callbackUrl) params.set('callbackUrl', callback.callbackUrl)
|
||||||
params.set('token', token)
|
params.set('token', token)
|
||||||
params.set('email', callback.email.toLowerCase())
|
params.set('email', callback.email)
|
||||||
const url = `/api/auth/callback/email?${params.toString()}`
|
const url = `/api/auth/callback/email?${params.toString()}`
|
||||||
router.push(url)
|
router.push(url)
|
||||||
}, [callback, router])
|
}, [callback, router])
|
||||||
|
@ -6,7 +6,7 @@ import { useRouter } from 'next/router'
|
|||||||
import PageLoading from '@/components/page-loading'
|
import PageLoading from '@/components/page-loading'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import { UserAnalyticsHeader } from '@/components/user-analytics-header'
|
import { UsageHeader } from '@/components/usage-header'
|
||||||
import { SatisticsHeader } from '..'
|
import { SatisticsHeader } from '..'
|
||||||
import { WhenComposedChartSkeleton, WhenAreaChartSkeleton } from '@/components/charts-skeletons'
|
import { WhenComposedChartSkeleton, WhenAreaChartSkeleton } from '@/components/charts-skeletons'
|
||||||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
|
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
|
||||||
@ -55,7 +55,7 @@ export default function Satistics ({ ssrData }) {
|
|||||||
<SatisticsHeader />
|
<SatisticsHeader />
|
||||||
<div className='tab-content' id='myTabContent'>
|
<div className='tab-content' id='myTabContent'>
|
||||||
<div className='tab-pane fade show active text-muted' id='statistics' role='tabpanel' aria-labelledby='statistics-tab'>
|
<div className='tab-pane fade show active text-muted' id='statistics' role='tabpanel' aria-labelledby='statistics-tab'>
|
||||||
<UserAnalyticsHeader pathname='satistics/graphs' />
|
<UsageHeader pathname='satistics/graphs' />
|
||||||
<div className='mt-3'>
|
<div className='mt-3'>
|
||||||
<div className='d-flex row justify-content-between'>
|
<div className='d-flex row justify-content-between'>
|
||||||
<div className='col-md-6 mb-2'>
|
<div className='col-md-6 mb-2'>
|
||||||
|
@ -1,157 +0,0 @@
|
|||||||
import { gql, useQuery } from '@apollo/client'
|
|
||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
|
||||||
import Layout from '@/components/layout'
|
|
||||||
import Col from 'react-bootstrap/Col'
|
|
||||||
import Row from 'react-bootstrap/Row'
|
|
||||||
import { SubAnalyticsHeader } from '@/components/sub-analytics-header'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import PageLoading from '@/components/page-loading'
|
|
||||||
import { WhenAreaChartSkeleton, WhenComposedChartSkeleton, WhenLineChartSkeleton } from '@/components/charts-skeletons'
|
|
||||||
|
|
||||||
const WhenAreaChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenAreaChart), {
|
|
||||||
loading: () => <WhenAreaChartSkeleton />
|
|
||||||
})
|
|
||||||
const WhenLineChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenLineChart), {
|
|
||||||
loading: () => <WhenLineChartSkeleton />
|
|
||||||
})
|
|
||||||
const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), {
|
|
||||||
loading: () => <WhenComposedChartSkeleton />
|
|
||||||
})
|
|
||||||
|
|
||||||
const GROWTH_QUERY = gql`
|
|
||||||
query Growth($when: String!, $from: String, $to: String, $sub: String, $subSelect: Boolean = false)
|
|
||||||
{
|
|
||||||
registrationGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
|
|
||||||
time
|
|
||||||
data {
|
|
||||||
name
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
itemGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
|
|
||||||
time
|
|
||||||
data {
|
|
||||||
name
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
spendingGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
|
|
||||||
time
|
|
||||||
data {
|
|
||||||
name
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
spenderGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
|
|
||||||
time
|
|
||||||
data {
|
|
||||||
name
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stackingGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
|
|
||||||
time
|
|
||||||
data {
|
|
||||||
name
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stackerGrowth(when: $when, from: $from, to: $to) @skip(if: $subSelect) {
|
|
||||||
time
|
|
||||||
data {
|
|
||||||
name
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
itemGrowthSubs(when: $when, from: $from, to: $to, sub: $sub) @include(if: $subSelect) {
|
|
||||||
time
|
|
||||||
data {
|
|
||||||
name
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
revenueGrowthSubs(when: $when, from: $from, to: $to, sub: $sub) @include(if: $subSelect) {
|
|
||||||
time
|
|
||||||
data {
|
|
||||||
name
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
const variablesFunc = vars => ({ ...vars, subSelect: vars.sub !== 'all' })
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: GROWTH_QUERY, variables: variablesFunc })
|
|
||||||
|
|
||||||
export default function Growth ({ ssrData }) {
|
|
||||||
const router = useRouter()
|
|
||||||
const { when, from, to, sub } = router.query
|
|
||||||
|
|
||||||
const { data } = useQuery(GROWTH_QUERY, { variables: { when, from, to, sub, subSelect: sub !== 'all' } })
|
|
||||||
if (!data && !ssrData) return <PageLoading />
|
|
||||||
|
|
||||||
const {
|
|
||||||
registrationGrowth,
|
|
||||||
itemGrowth,
|
|
||||||
spendingGrowth,
|
|
||||||
spenderGrowth,
|
|
||||||
stackingGrowth,
|
|
||||||
stackerGrowth,
|
|
||||||
itemGrowthSubs,
|
|
||||||
revenueGrowthSubs
|
|
||||||
} = data || ssrData
|
|
||||||
|
|
||||||
if (sub === 'all') {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<SubAnalyticsHeader />
|
|
||||||
<Row>
|
|
||||||
<Col className='mt-3'>
|
|
||||||
<div className='text-center text-muted fw-bold'>stackers</div>
|
|
||||||
<WhenLineChart data={stackerGrowth} />
|
|
||||||
</Col>
|
|
||||||
<Col className='mt-3'>
|
|
||||||
<div className='text-center text-muted fw-bold'>stacking</div>
|
|
||||||
<WhenAreaChart data={stackingGrowth} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row>
|
|
||||||
<Col className='mt-3'>
|
|
||||||
<div className='text-center text-muted fw-bold'>spenders</div>
|
|
||||||
<WhenLineChart data={spenderGrowth} />
|
|
||||||
</Col>
|
|
||||||
<Col className='mt-3'>
|
|
||||||
<div className='text-center text-muted fw-bold'>spending</div>
|
|
||||||
<WhenAreaChart data={spendingGrowth} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row>
|
|
||||||
<Col className='mt-3'>
|
|
||||||
<div className='text-center text-muted fw-bold'>registrations</div>
|
|
||||||
<WhenAreaChart data={registrationGrowth} />
|
|
||||||
</Col>
|
|
||||||
<Col className='mt-3'>
|
|
||||||
<div className='text-center text-muted fw-bold'>items</div>
|
|
||||||
<WhenComposedChart data={itemGrowth} areaNames={['posts', 'comments', 'jobs']} areaAxis='left' lineNames={['comments/posts', 'territories']} lineAxis='right' barNames={['zaps']} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<SubAnalyticsHeader />
|
|
||||||
<Row>
|
|
||||||
<Col className='mt-3'>
|
|
||||||
<div className='text-center text-muted fw-bold'>items</div>
|
|
||||||
<WhenLineChart data={itemGrowthSubs} />
|
|
||||||
</Col>
|
|
||||||
<Col className='mt-3'>
|
|
||||||
<div className='text-center text-muted fw-bold'>sats</div>
|
|
||||||
<WhenLineChart data={revenueGrowthSubs} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
115
pages/stackers/[when].js
Normal file
115
pages/stackers/[when].js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { gql, useQuery } from '@apollo/client'
|
||||||
|
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||||
|
import Layout from '@/components/layout'
|
||||||
|
import Col from 'react-bootstrap/Col'
|
||||||
|
import Row from 'react-bootstrap/Row'
|
||||||
|
import { UsageHeader } from '@/components/usage-header'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import PageLoading from '@/components/page-loading'
|
||||||
|
import { WhenAreaChartSkeleton, WhenComposedChartSkeleton, WhenLineChartSkeleton } from '@/components/charts-skeletons'
|
||||||
|
|
||||||
|
const WhenAreaChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenAreaChart), {
|
||||||
|
loading: () => <WhenAreaChartSkeleton />
|
||||||
|
})
|
||||||
|
const WhenLineChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenLineChart), {
|
||||||
|
loading: () => <WhenLineChartSkeleton />
|
||||||
|
})
|
||||||
|
const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), {
|
||||||
|
loading: () => <WhenComposedChartSkeleton />
|
||||||
|
})
|
||||||
|
|
||||||
|
const GROWTH_QUERY = gql`
|
||||||
|
query Growth($when: String!, $from: String, $to: String)
|
||||||
|
{
|
||||||
|
registrationGrowth(when: $when, from: $from, to: $to) {
|
||||||
|
time
|
||||||
|
data {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemGrowth(when: $when, from: $from, to: $to) {
|
||||||
|
time
|
||||||
|
data {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spendingGrowth(when: $when, from: $from, to: $to) {
|
||||||
|
time
|
||||||
|
data {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spenderGrowth(when: $when, from: $from, to: $to) {
|
||||||
|
time
|
||||||
|
data {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stackingGrowth(when: $when, from: $from, to: $to) {
|
||||||
|
time
|
||||||
|
data {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stackerGrowth(when: $when, from: $from, to: $to) {
|
||||||
|
time
|
||||||
|
data {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
export const getServerSideProps = getGetServerSideProps({ query: GROWTH_QUERY })
|
||||||
|
|
||||||
|
export default function Growth ({ ssrData }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { when, from, to } = router.query
|
||||||
|
|
||||||
|
const { data } = useQuery(GROWTH_QUERY, { variables: { when, from, to } })
|
||||||
|
if (!data && !ssrData) return <PageLoading />
|
||||||
|
|
||||||
|
const { registrationGrowth, itemGrowth, spendingGrowth, spenderGrowth, stackingGrowth, stackerGrowth } = data || ssrData
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<UsageHeader />
|
||||||
|
<Row>
|
||||||
|
<Col className='mt-3'>
|
||||||
|
<div className='text-center text-muted fw-bold'>stackers</div>
|
||||||
|
<WhenLineChart data={stackerGrowth} />
|
||||||
|
</Col>
|
||||||
|
<Col className='mt-3'>
|
||||||
|
<div className='text-center text-muted fw-bold'>stacking</div>
|
||||||
|
<WhenAreaChart data={stackingGrowth} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col className='mt-3'>
|
||||||
|
<div className='text-center text-muted fw-bold'>spenders</div>
|
||||||
|
<WhenLineChart data={spenderGrowth} />
|
||||||
|
</Col>
|
||||||
|
<Col className='mt-3'>
|
||||||
|
<div className='text-center text-muted fw-bold'>spending</div>
|
||||||
|
<WhenAreaChart data={spendingGrowth} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col className='mt-3'>
|
||||||
|
<div className='text-center text-muted fw-bold'>registrations</div>
|
||||||
|
<WhenAreaChart data={registrationGrowth} />
|
||||||
|
</Col>
|
||||||
|
<Col className='mt-3'>
|
||||||
|
<div className='text-center text-muted fw-bold'>items</div>
|
||||||
|
<WhenComposedChart data={itemGrowth} areaNames={['posts', 'comments', 'jobs']} areaAxis='left' lineNames={['comments/posts', 'territories']} lineAxis='right' barNames={['zaps']} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
@ -15,6 +15,7 @@ import { lnAddrSchema, withdrawlSchema } from '@/lib/validate'
|
|||||||
import { useShowModal } from '@/components/modal'
|
import { useShowModal } from '@/components/modal'
|
||||||
import { useField } from 'formik'
|
import { useField } from 'formik'
|
||||||
import { useToast } from '@/components/toast'
|
import { useToast } from '@/components/toast'
|
||||||
|
import { Scanner } from '@yudiel/react-qr-scanner'
|
||||||
import { decode } from 'bolt11'
|
import { decode } from 'bolt11'
|
||||||
import CameraIcon from '@/svgs/camera-line.svg'
|
import CameraIcon from '@/svgs/camera-line.svg'
|
||||||
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||||
@ -23,8 +24,6 @@ import useDebounceCallback from '@/components/use-debounce-callback'
|
|||||||
import { lnAddrOptions } from '@/lib/lnurl'
|
import { lnAddrOptions } from '@/lib/lnurl'
|
||||||
import AccordianItem from '@/components/accordian-item'
|
import AccordianItem from '@/components/accordian-item'
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import PageLoading from '@/components/page-loading'
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||||
|
|
||||||
@ -154,47 +153,39 @@ function InvoiceScanner ({ fieldName }) {
|
|||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const [,, helpers] = useField(fieldName)
|
const [,, helpers] = useField(fieldName)
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const Scanner = dynamic(() => import('@yudiel/react-qr-scanner').then(mod => mod.Scanner), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <PageLoading />
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputGroup.Text
|
<InputGroup.Text
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showModal(onClose => {
|
showModal(onClose => {
|
||||||
return (
|
return (
|
||||||
<>
|
<Scanner
|
||||||
{Scanner && (
|
formats={['qr_code']}
|
||||||
<Scanner
|
onScan={([{ rawValue: result }]) => {
|
||||||
formats={['qr_code']}
|
result = result.toLowerCase()
|
||||||
onScan={([{ rawValue: result }]) => {
|
if (result.split('lightning=')[1]) {
|
||||||
result = result.toLowerCase()
|
helpers.setValue(result.split('lightning=')[1].split(/[&?]/)[0])
|
||||||
if (result.split('lightning=')[1]) {
|
} else if (decode(result.replace(/^lightning:/, ''))) {
|
||||||
helpers.setValue(result.split('lightning=')[1].split(/[&?]/)[0])
|
helpers.setValue(result.replace(/^lightning:/, ''))
|
||||||
} else if (decode(result.replace(/^lightning:/, ''))) {
|
} else {
|
||||||
helpers.setValue(result.replace(/^lightning:/, ''))
|
throw new Error('Not a proper lightning payment request')
|
||||||
} else {
|
}
|
||||||
throw new Error('Not a proper lightning payment request')
|
onClose()
|
||||||
}
|
}}
|
||||||
onClose()
|
styles={{
|
||||||
}}
|
video: {
|
||||||
styles={{
|
aspectRatio: '1 / 1'
|
||||||
video: {
|
}
|
||||||
aspectRatio: '1 / 1'
|
}}
|
||||||
}
|
onError={(error) => {
|
||||||
}}
|
if (error instanceof DOMException) {
|
||||||
onError={(error) => {
|
console.log(error)
|
||||||
if (error instanceof DOMException) {
|
} else {
|
||||||
console.log(error)
|
toaster.danger('qr scan: ' + error?.message || error?.toString?.())
|
||||||
} else {
|
}
|
||||||
toaster.danger('qr scan: ' + error?.message || error?.toString?.())
|
onClose()
|
||||||
}
|
}}
|
||||||
onClose()
|
/>
|
||||||
}}
|
|
||||||
/>)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
@ -6,7 +6,6 @@ import UserList from '@/components/user-list'
|
|||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: TOP_COWBOYS })
|
export const getServerSideProps = getGetServerSideProps({ query: TOP_COWBOYS })
|
||||||
|
|
||||||
// if a when descriptor is provided, it redirects here; see next.config.js
|
|
||||||
export default function Index ({ ssrData }) {
|
export default function Index ({ ssrData }) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Invoice" ADD COLUMN "paymentAttempt" INTEGER NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE "Invoice" ADD COLUMN "retryPendingSince" TIMESTAMP(3);
|
|
||||||
CREATE INDEX "Invoice_cancelledAt_idx" ON "Invoice"("cancelledAt");
|
|
@ -928,8 +928,6 @@ model Invoice {
|
|||||||
cancelled Boolean @default(false)
|
cancelled Boolean @default(false)
|
||||||
cancelledAt DateTime?
|
cancelledAt DateTime?
|
||||||
userCancel Boolean?
|
userCancel Boolean?
|
||||||
paymentAttempt Int @default(0)
|
|
||||||
retryPendingSince DateTime?
|
|
||||||
msatsRequested BigInt
|
msatsRequested BigInt
|
||||||
msatsReceived BigInt?
|
msatsReceived BigInt?
|
||||||
desc String?
|
desc String?
|
||||||
@ -958,7 +956,6 @@ model Invoice {
|
|||||||
@@index([confirmedIndex], map: "Invoice.confirmedIndex_index")
|
@@index([confirmedIndex], map: "Invoice.confirmedIndex_index")
|
||||||
@@index([isHeld])
|
@@index([isHeld])
|
||||||
@@index([confirmedAt])
|
@@index([confirmedAt])
|
||||||
@@index([cancelledAt])
|
|
||||||
@@index([actionType])
|
@@index([actionType])
|
||||||
@@index([actionState])
|
@@index([actionState])
|
||||||
}
|
}
|
||||||
|
@ -1,543 +0,0 @@
|
|||||||
const WebSocket = require('ws') // You might need to install this: npm install ws
|
|
||||||
const { nip19 } = require('nostr-tools') // Keep this for formatting
|
|
||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
|
||||||
|
|
||||||
// ANSI color codes
|
|
||||||
const colors = {
|
|
||||||
reset: '\x1b[0m',
|
|
||||||
bright: '\x1b[1m',
|
|
||||||
dim: '\x1b[2m',
|
|
||||||
underscore: '\x1b[4m',
|
|
||||||
blink: '\x1b[5m',
|
|
||||||
reverse: '\x1b[7m',
|
|
||||||
hidden: '\x1b[8m',
|
|
||||||
|
|
||||||
fg: {
|
|
||||||
black: '\x1b[30m',
|
|
||||||
red: '\x1b[31m',
|
|
||||||
green: '\x1b[32m',
|
|
||||||
yellow: '\x1b[33m',
|
|
||||||
blue: '\x1b[34m',
|
|
||||||
magenta: '\x1b[35m',
|
|
||||||
cyan: '\x1b[36m',
|
|
||||||
white: '\x1b[37m',
|
|
||||||
gray: '\x1b[90m',
|
|
||||||
crimson: '\x1b[38m'
|
|
||||||
},
|
|
||||||
bg: {
|
|
||||||
black: '\x1b[40m',
|
|
||||||
red: '\x1b[41m',
|
|
||||||
green: '\x1b[42m',
|
|
||||||
yellow: '\x1b[43m',
|
|
||||||
blue: '\x1b[44m',
|
|
||||||
magenta: '\x1b[45m',
|
|
||||||
cyan: '\x1b[46m',
|
|
||||||
white: '\x1b[47m',
|
|
||||||
gray: '\x1b[100m',
|
|
||||||
crimson: '\x1b[48m'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default configuration
|
|
||||||
let config = {
|
|
||||||
userPubkeys: [],
|
|
||||||
ignorePubkeys: [],
|
|
||||||
timeIntervalHours: 12,
|
|
||||||
verbosity: 'normal', // Can be 'minimal', 'normal', or 'debug'
|
|
||||||
relayUrls: [
|
|
||||||
'wss://relay.nostr.band',
|
|
||||||
'wss://relay.primal.net',
|
|
||||||
'wss://relay.damus.io'
|
|
||||||
],
|
|
||||||
batchSize: 100,
|
|
||||||
mediaPatterns: [
|
|
||||||
{
|
|
||||||
type: 'extensions',
|
|
||||||
patterns: ['\\.jpg$', '\\.jpeg$', '\\.png$', '\\.gif$', '\\.bmp$', '\\.webp$', '\\.tiff$', '\\.ico$',
|
|
||||||
'\\.mp4$', '\\.webm$', '\\.mov$', '\\.avi$', '\\.mkv$', '\\.flv$', '\\.wmv$',
|
|
||||||
'\\.mp3$', '\\.wav$', '\\.ogg$', '\\.flac$', '\\.aac$', '\\.m4a$']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'domains',
|
|
||||||
patterns: [
|
|
||||||
'nostr\\.build\\/[ai]\\/\\w+',
|
|
||||||
'i\\.imgur\\.com\\/\\w+',
|
|
||||||
'i\\.ibb\\.co\\/\\w+\\/',
|
|
||||||
'tenor\\.com\\/view\\/',
|
|
||||||
'giphy\\.com\\/gifs\\/',
|
|
||||||
'soundcloud\\.com\\/',
|
|
||||||
'spotify\\.com\\/',
|
|
||||||
'fountain\\.fm\\/'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logger utility that respects the configured verbosity level
|
|
||||||
*/
|
|
||||||
const logger = {
|
|
||||||
// Always show error messages
|
|
||||||
error: (message) => {
|
|
||||||
console.error(`${colors.fg.red}Error: ${message}${colors.reset}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Minimal essential info - always show regardless of verbosity
|
|
||||||
info: (message) => {
|
|
||||||
console.log(`${colors.fg.green}${message}${colors.reset}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Progress updates - show in normal and debug modes
|
|
||||||
progress: (message) => {
|
|
||||||
if (config.verbosity !== 'minimal') {
|
|
||||||
console.log(`${colors.fg.blue}${message}${colors.reset}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Detailed debug info - only show in debug mode
|
|
||||||
debug: (message) => {
|
|
||||||
if (config.verbosity === 'debug') {
|
|
||||||
console.log(`${colors.fg.gray}${message}${colors.reset}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Results info - formatted differently for clarity
|
|
||||||
result: (message) => {
|
|
||||||
console.log(`${colors.bright}${colors.fg.green}${message}${colors.reset}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load configuration from a JSON file
|
|
||||||
* @param {String} configPath - Path to the config file
|
|
||||||
* @returns {Object} - Configuration object
|
|
||||||
*/
|
|
||||||
function loadConfig (configPath) {
|
|
||||||
try {
|
|
||||||
const configData = fs.readFileSync(configPath, 'utf8')
|
|
||||||
const loadedConfig = JSON.parse(configData)
|
|
||||||
|
|
||||||
// Merge with default config to ensure all properties exist
|
|
||||||
return { ...config, ...loadedConfig }
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error loading config file: ${error.message}`)
|
|
||||||
logger.info('Using default configuration')
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a URL is a media file or hosted on a media platform based on configured patterns
|
|
||||||
* @param {String} url - URL to check
|
|
||||||
* @returns {Boolean} - true if it's likely a media URL
|
|
||||||
*/
|
|
||||||
function isMediaUrl (url) {
|
|
||||||
// Check for media patterns from config
|
|
||||||
if (config.mediaPatterns) {
|
|
||||||
for (const patternGroup of config.mediaPatterns) {
|
|
||||||
for (const pattern of patternGroup.patterns) {
|
|
||||||
const regex = new RegExp(pattern, 'i')
|
|
||||||
if (regex.test(url)) return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches events from Nostr relays using WebSockets
|
|
||||||
* @param {Array} relayUrls - Array of relay URLs
|
|
||||||
* @param {Object} filter - Nostr filter object
|
|
||||||
* @param {Number} timeoutMs - Timeout in milliseconds
|
|
||||||
* @returns {Promise<Array>} - Array of events matching the filter
|
|
||||||
*/
|
|
||||||
async function fetchEvents (relayUrls, filter, timeoutMs = 10000) {
|
|
||||||
logger.debug(`Fetching events with filter: ${JSON.stringify(filter)}`)
|
|
||||||
const events = []
|
|
||||||
|
|
||||||
for (const url of relayUrls) {
|
|
||||||
try {
|
|
||||||
const ws = new WebSocket(url)
|
|
||||||
|
|
||||||
const relayEvents = await new Promise((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
ws.close()
|
|
||||||
resolve([]) // Resolve with empty array on timeout
|
|
||||||
}, timeoutMs)
|
|
||||||
|
|
||||||
const localEvents = []
|
|
||||||
|
|
||||||
ws.on('open', () => {
|
|
||||||
// Create a unique request ID
|
|
||||||
const requestId = `req${Math.floor(Math.random() * 10000)}`
|
|
||||||
|
|
||||||
// Format and send the request
|
|
||||||
const request = JSON.stringify(['REQ', requestId, filter])
|
|
||||||
ws.send(request)
|
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(data.toString())
|
|
||||||
|
|
||||||
// Check if it's an EVENT message
|
|
||||||
if (message[0] === 'EVENT' && message[2]) {
|
|
||||||
localEvents.push(message[2])
|
|
||||||
} else if (message[0] === 'EOSE') {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
ws.close()
|
|
||||||
resolve(localEvents)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug(`Error parsing message: ${error.message}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
ws.on('error', (error) => {
|
|
||||||
logger.debug(`WebSocket error for ${url}: ${error.message}`)
|
|
||||||
clearTimeout(timeout)
|
|
||||||
resolve([]) // Resolve with empty array on error
|
|
||||||
})
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
resolve(localEvents)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.debug(`Got ${relayEvents.length} events from ${url}`)
|
|
||||||
events.push(...relayEvents)
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug(`Error connecting to ${url}: ${error.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove duplicates based on event ID
|
|
||||||
const uniqueEvents = {}
|
|
||||||
events.forEach(event => {
|
|
||||||
if (!uniqueEvents[event.id]) {
|
|
||||||
uniqueEvents[event.id] = event
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Object.values(uniqueEvents)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Nostr notes from followings of specified users that contain external links
|
|
||||||
* and were posted within the specified time interval.
|
|
||||||
*
|
|
||||||
* @param {Array} userPubkeys - Array of Nostr user public keys
|
|
||||||
* @param {Number} timeIntervalHours - Number of hours to look back from now
|
|
||||||
* @param {Array} relayUrls - Array of Nostr relay URLs
|
|
||||||
* @param {Array} ignorePubkeys - Array of pubkeys to ignore (optional)
|
|
||||||
* @returns {Promise<Array>} - Array of note objects containing external links within the time interval
|
|
||||||
*/
|
|
||||||
async function getNotesWithLinks (userPubkeys, timeIntervalHours, relayUrls, ignorePubkeys = []) {
|
|
||||||
// Calculate the cutoff time in seconds (Nostr uses UNIX timestamp)
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
|
||||||
const cutoffTime = now - (timeIntervalHours * 60 * 60)
|
|
||||||
|
|
||||||
const allNotesWithLinks = []
|
|
||||||
const allFollowedPubkeys = new Set() // To collect all followed pubkeys
|
|
||||||
const ignoreSet = new Set(ignorePubkeys) // Convert ignore list to Set for efficient lookups
|
|
||||||
|
|
||||||
if (ignoreSet.size > 0) {
|
|
||||||
logger.debug(`Ignoring ${ignoreSet.size} author(s) as requested`)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Fetching follow lists for ${userPubkeys.length} users...`)
|
|
||||||
// First get the followings for each user
|
|
||||||
for (const pubkey of userPubkeys) {
|
|
||||||
try {
|
|
||||||
// Skip if this pubkey is in the ignore list
|
|
||||||
if (ignoreSet.has(pubkey)) {
|
|
||||||
logger.debug(`Skipping user ${pubkey} as it's in the ignore list`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`Fetching follow list for ${pubkey} from ${relayUrls.length} relays...`)
|
|
||||||
|
|
||||||
// Get the most recent contact list (kind 3)
|
|
||||||
const followListEvents = await fetchEvents(relayUrls, {
|
|
||||||
kinds: [3],
|
|
||||||
authors: [pubkey]
|
|
||||||
})
|
|
||||||
|
|
||||||
if (followListEvents.length === 0) {
|
|
||||||
logger.debug(`No follow list found for user ${pubkey}. Verify this pubkey has contacts on these relays.`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the most recent follow list event
|
|
||||||
const latestFollowList = followListEvents.reduce((latest, event) =>
|
|
||||||
!latest || event.created_at > latest.created_at ? event : latest, null)
|
|
||||||
|
|
||||||
if (!latestFollowList) {
|
|
||||||
logger.debug(`No valid follow list found for user ${pubkey}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`Found follow list created at: ${new Date(latestFollowList.created_at * 1000).toISOString()}`)
|
|
||||||
|
|
||||||
// Check if tags property exists
|
|
||||||
if (!latestFollowList.tags) {
|
|
||||||
logger.debug(`No tags found in follow list for user ${pubkey}`)
|
|
||||||
logger.debug('Follow list data:', JSON.stringify(latestFollowList, null, 2))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract followed pubkeys from the follow list (tag type 'p')
|
|
||||||
const followedPubkeys = latestFollowList.tags
|
|
||||||
.filter(tag => tag[0] === 'p')
|
|
||||||
.map(tag => tag[1])
|
|
||||||
.filter(pk => !ignoreSet.has(pk)) // Filter out pubkeys from the ignore list
|
|
||||||
|
|
||||||
if (!followedPubkeys || followedPubkeys.length === 0) {
|
|
||||||
logger.debug(`No followed users found for user ${pubkey} (after filtering ignore list)`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add all followed pubkeys to our set
|
|
||||||
followedPubkeys.forEach(pk => allFollowedPubkeys.add(pk))
|
|
||||||
|
|
||||||
logger.debug(`Added ${followedPubkeys.length} followed users for ${pubkey} (total: ${allFollowedPubkeys.size})`)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error processing user ${pubkey}: ${error}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we found any followed pubkeys, fetch their notes in batches
|
|
||||||
if (allFollowedPubkeys.size > 0) {
|
|
||||||
// Convert Set to Array for the filter
|
|
||||||
const followedPubkeysArray = Array.from(allFollowedPubkeys)
|
|
||||||
const batchSize = config.batchSize || 100 // Use config batch size or default to 100
|
|
||||||
const totalBatches = Math.ceil(followedPubkeysArray.length / batchSize)
|
|
||||||
|
|
||||||
logger.progress(`Processing ${followedPubkeysArray.length} followed users in ${totalBatches} batches...`)
|
|
||||||
|
|
||||||
// Process in batches
|
|
||||||
for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
|
|
||||||
const start = batchNum * batchSize
|
|
||||||
const end = Math.min(start + batchSize, followedPubkeysArray.length)
|
|
||||||
const batch = followedPubkeysArray.slice(start, end)
|
|
||||||
|
|
||||||
logger.progress(`Fetching batch ${batchNum + 1}/${totalBatches} (${batch.length} authors)...`)
|
|
||||||
|
|
||||||
// Fetch notes from the current batch of users
|
|
||||||
const notes = await fetchEvents(relayUrls, {
|
|
||||||
kinds: [1],
|
|
||||||
authors: batch,
|
|
||||||
since: cutoffTime
|
|
||||||
}, 30000) // Use a longer timeout for this larger query
|
|
||||||
|
|
||||||
logger.debug(`Retrieved ${notes.length} notes from batch ${batchNum + 1}`)
|
|
||||||
|
|
||||||
// Filter notes that have URLs (excluding notes with only media URLs)
|
|
||||||
const notesWithUrls = notes.filter(note => {
|
|
||||||
// Extract all URLs from content
|
|
||||||
const urlRegex = /(https?:\/\/[^\s]+)/g
|
|
||||||
const matches = note.content.match(urlRegex) || []
|
|
||||||
|
|
||||||
if (matches.length === 0) return false // No URLs at all
|
|
||||||
|
|
||||||
// Check if any URL is not a media file
|
|
||||||
const hasNonMediaUrl = matches.some(url => !isMediaUrl(url))
|
|
||||||
|
|
||||||
return hasNonMediaUrl
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.debug(`Found ${notesWithUrls.length} notes containing non-media URLs in batch ${batchNum + 1}`)
|
|
||||||
|
|
||||||
// Get all unique authors from the filtered notes in this batch
|
|
||||||
const authorsWithUrls = new Set(notesWithUrls.map(note => note.pubkey))
|
|
||||||
|
|
||||||
// Fetch metadata for all relevant authors in this batch
|
|
||||||
if (authorsWithUrls.size > 0) {
|
|
||||||
logger.debug(`Fetching metadata for ${authorsWithUrls.size} authors from batch ${batchNum + 1}...`)
|
|
||||||
const allMetadata = await fetchEvents(relayUrls, {
|
|
||||||
kinds: [0],
|
|
||||||
authors: Array.from(authorsWithUrls)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create a map of author pubkey to their latest metadata
|
|
||||||
const metadataByAuthor = {}
|
|
||||||
allMetadata.forEach(meta => {
|
|
||||||
if (!metadataByAuthor[meta.pubkey] || meta.created_at > metadataByAuthor[meta.pubkey].created_at) {
|
|
||||||
metadataByAuthor[meta.pubkey] = meta
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Attach metadata to notes
|
|
||||||
for (const note of notesWithUrls) {
|
|
||||||
if (metadataByAuthor[note.pubkey]) {
|
|
||||||
try {
|
|
||||||
const metadata = JSON.parse(metadataByAuthor[note.pubkey].content)
|
|
||||||
note.userMetadata = metadata
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug(`Error parsing metadata for ${note.pubkey}: ${e.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add all notes with URLs from this batch to our results
|
|
||||||
allNotesWithLinks.push(...notesWithUrls)
|
|
||||||
|
|
||||||
// Show incremental progress during batch processing
|
|
||||||
if (allNotesWithLinks.length > 0 && batchNum < totalBatches - 1) {
|
|
||||||
logger.progress(`Found ${allNotesWithLinks.length} notes with links so far...`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.progress(`Completed processing all ${totalBatches} batches`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return allNotesWithLinks
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format the notes for display with colorful output
|
|
||||||
*
|
|
||||||
* @param {Array} notes - Array of note objects
|
|
||||||
* @returns {String} - Formatted string with note information
|
|
||||||
*/
|
|
||||||
function formatNoteOutput (notes) {
|
|
||||||
const output = []
|
|
||||||
|
|
||||||
for (const note of notes) {
|
|
||||||
// Get note ID as npub
|
|
||||||
const noteId = nip19.noteEncode(note.id)
|
|
||||||
const pubkey = nip19.npubEncode(note.pubkey)
|
|
||||||
|
|
||||||
// Get user display name or fall back to npub
|
|
||||||
const userName = note.userMetadata
|
|
||||||
? (note.userMetadata.display_name || note.userMetadata.name || pubkey)
|
|
||||||
: pubkey
|
|
||||||
|
|
||||||
// Get timestamp as readable date
|
|
||||||
const timestamp = new Date(note.created_at * 1000).toISOString()
|
|
||||||
|
|
||||||
// Extract URLs from content, marking media URLs with colors
|
|
||||||
const urlRegex = /(https?:\/\/[^\s]+)/g
|
|
||||||
const matches = note.content.match(urlRegex) || []
|
|
||||||
|
|
||||||
// Format URLs with colors
|
|
||||||
const markedUrls = matches.map(url => {
|
|
||||||
const isMedia = isMediaUrl(url)
|
|
||||||
if (isMedia) {
|
|
||||||
return `${colors.fg.gray}${url}${colors.reset} (media)`
|
|
||||||
} else {
|
|
||||||
return `${colors.bright}${colors.fg.cyan}${url}${colors.reset}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Format output with colors
|
|
||||||
output.push(`${colors.bright}${colors.fg.yellow}Note by ${colors.fg.magenta}${userName}${colors.fg.yellow} at ${timestamp}${colors.reset}`)
|
|
||||||
output.push(`${colors.fg.green}Note ID: ${colors.reset}${noteId}`)
|
|
||||||
output.push(`${colors.fg.green}Pubkey: ${colors.reset}${pubkey}`)
|
|
||||||
|
|
||||||
// Add links with a heading
|
|
||||||
output.push(`${colors.bright}${colors.fg.blue}External URLs:${colors.reset}`)
|
|
||||||
markedUrls.forEach(url => {
|
|
||||||
output.push(` • ${url}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add content with a heading
|
|
||||||
output.push(`${colors.bright}${colors.fg.blue}Note content:${colors.reset}`)
|
|
||||||
|
|
||||||
// Colorize any links in content when displaying
|
|
||||||
let coloredContent = note.content
|
|
||||||
for (const url of matches) {
|
|
||||||
const isMedia = isMediaUrl(url)
|
|
||||||
const colorCode = isMedia ? colors.fg.gray : colors.bright + colors.fg.cyan
|
|
||||||
coloredContent = coloredContent.replace(
|
|
||||||
new RegExp(escapeRegExp(url), 'g'),
|
|
||||||
`${colorCode}${url}${colors.reset}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
output.push(coloredContent)
|
|
||||||
|
|
||||||
output.push(`${colors.fg.yellow}${'-'.repeat(50)}${colors.reset}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return output.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape special characters for use in a regular expression
|
|
||||||
* @param {String} string - String to escape
|
|
||||||
* @returns {String} - Escaped string
|
|
||||||
*/
|
|
||||||
function escapeRegExp (string) {
|
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a pubkey from npub to hex format if needed
|
|
||||||
* @param {String} key - Pubkey in either npub or hex format
|
|
||||||
* @returns {String} - Pubkey in hex format
|
|
||||||
*/
|
|
||||||
function normalizeToHexPubkey (key) {
|
|
||||||
// If it's an npub, decode it
|
|
||||||
if (typeof key === 'string' && key.startsWith('npub1')) {
|
|
||||||
try {
|
|
||||||
const { type, data } = nip19.decode(key)
|
|
||||||
if (type === 'npub') {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(`Error decoding npub ${key}: ${e.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Otherwise assume it's already in hex format
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main function to execute the script
|
|
||||||
*/
|
|
||||||
async function main () {
|
|
||||||
// Load configuration from file
|
|
||||||
const configPath = path.join(__dirname, 'nostr-link-extract.config.json')
|
|
||||||
logger.info(`Loading configuration from ${configPath}`)
|
|
||||||
config = loadConfig(configPath)
|
|
||||||
|
|
||||||
try {
|
|
||||||
logger.info(`Starting Nostr link extraction (time interval: ${config.timeIntervalHours} hours)`)
|
|
||||||
|
|
||||||
// Convert any npub format keys to hex
|
|
||||||
const hexUserPubkeys = config.userPubkeys.map(normalizeToHexPubkey)
|
|
||||||
const hexIgnorePubkeys = config.ignorePubkeys.map(normalizeToHexPubkey)
|
|
||||||
|
|
||||||
// Log the conversion for clarity (helpful for debugging)
|
|
||||||
if (config.userPubkeys.some(key => key.startsWith('npub1'))) {
|
|
||||||
logger.debug('Converted user npubs to hex format for Nostr protocol')
|
|
||||||
}
|
|
||||||
if (config.ignorePubkeys.some(key => key.startsWith('npub1'))) {
|
|
||||||
logger.debug('Converted ignore list npubs to hex format for Nostr protocol')
|
|
||||||
}
|
|
||||||
|
|
||||||
const notesWithLinks = await getNotesWithLinks(
|
|
||||||
hexUserPubkeys,
|
|
||||||
config.timeIntervalHours,
|
|
||||||
config.relayUrls,
|
|
||||||
hexIgnorePubkeys
|
|
||||||
)
|
|
||||||
|
|
||||||
if (notesWithLinks.length > 0) {
|
|
||||||
const formattedOutput = formatNoteOutput(notesWithLinks)
|
|
||||||
console.log(formattedOutput)
|
|
||||||
logger.result(`Total notes with links: ${notesWithLinks.length}`)
|
|
||||||
} else {
|
|
||||||
logger.info('No notes with links found in the specified time interval.')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`${error}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the script
|
|
||||||
main()
|
|
@ -62,13 +62,6 @@ export class WalletsNotAvailableError extends WalletConfigurationError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnonWalletError extends WalletConfigurationError {
|
|
||||||
constructor () {
|
|
||||||
super('anon cannot pay with wallets')
|
|
||||||
this.name = 'AnonWalletError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WalletAggregateError extends WalletError {
|
export class WalletAggregateError extends WalletError {
|
||||||
constructor (errors, invoice) {
|
constructor (errors, invoice) {
|
||||||
super('WalletAggregateError')
|
super('WalletAggregateError')
|
||||||
|
@ -1,15 +1,12 @@
|
|||||||
import { useMe } from '@/components/me'
|
import { useMe } from '@/components/me'
|
||||||
import { FAILED_INVOICES, SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet'
|
import { SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet'
|
||||||
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
|
import { SSR } from '@/lib/constants'
|
||||||
import { useApolloClient, useLazyQuery, useMutation, useQuery } from '@apollo/client'
|
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
|
import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
|
||||||
import useVault from '@/components/vault/use-vault'
|
import useVault from '@/components/vault/use-vault'
|
||||||
import walletDefs from '@/wallets/client'
|
import walletDefs from '@/wallets/client'
|
||||||
import { generateMutation } from './graphql'
|
import { generateMutation } from './graphql'
|
||||||
import { useWalletPayment } from './payment'
|
|
||||||
import useInvoice from '@/components/use-invoice'
|
|
||||||
import { WalletConfigurationError } from './errors'
|
|
||||||
|
|
||||||
const WalletsContext = createContext({
|
const WalletsContext = createContext({
|
||||||
wallets: []
|
wallets: []
|
||||||
@ -207,9 +204,7 @@ export function WalletsProvider ({ children }) {
|
|||||||
removeLocalWallets
|
removeLocalWallets
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RetryHandler>
|
{children}
|
||||||
{children}
|
|
||||||
</RetryHandler>
|
|
||||||
</WalletsContext.Provider>
|
</WalletsContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -226,79 +221,7 @@ export function useWallet (name) {
|
|||||||
export function useSendWallets () {
|
export function useSendWallets () {
|
||||||
const { wallets } = useWallets()
|
const { wallets } = useWallets()
|
||||||
// return all enabled wallets that are available and can send
|
// return all enabled wallets that are available and can send
|
||||||
return useMemo(() => wallets
|
return wallets
|
||||||
.filter(w => !w.def.isAvailable || w.def.isAvailable())
|
.filter(w => !w.def.isAvailable || w.def.isAvailable())
|
||||||
.filter(w => w.config?.enabled && canSend(w)), [wallets])
|
.filter(w => w.config?.enabled && canSend(w))
|
||||||
}
|
|
||||||
|
|
||||||
function RetryHandler ({ children }) {
|
|
||||||
const wallets = useSendWallets()
|
|
||||||
const waitForWalletPayment = useWalletPayment()
|
|
||||||
const invoiceHelper = useInvoice()
|
|
||||||
const [getFailedInvoices] = useLazyQuery(FAILED_INVOICES, { fetchPolicy: 'network-only', nextFetchPolicy: 'network-only' })
|
|
||||||
|
|
||||||
const retry = useCallback(async (invoice) => {
|
|
||||||
const newInvoice = await invoiceHelper.retry({ ...invoice, newAttempt: true })
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForWalletPayment(newInvoice)
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof WalletConfigurationError) {
|
|
||||||
// consume attempt by canceling invoice
|
|
||||||
await invoiceHelper.cancel(newInvoice)
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}, [invoiceHelper, waitForWalletPayment])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// we always retry failed invoices, even if the user has no wallets on any client
|
|
||||||
// to make sure that failed payments will always show up in notifications eventually
|
|
||||||
|
|
||||||
const retryPoll = async () => {
|
|
||||||
let failedInvoices
|
|
||||||
try {
|
|
||||||
const { data, error } = await getFailedInvoices()
|
|
||||||
if (error) throw error
|
|
||||||
failedInvoices = data.failedInvoices
|
|
||||||
} catch (err) {
|
|
||||||
console.error('failed to fetch invoices to retry:', err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const inv of failedInvoices) {
|
|
||||||
try {
|
|
||||||
await retry(inv)
|
|
||||||
} catch (err) {
|
|
||||||
// some retries are expected to fail since only one client at a time is allowed to retry
|
|
||||||
// these should show up as 'invoice not found' errors
|
|
||||||
console.error('retry failed:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeout, stopped
|
|
||||||
const queuePoll = () => {
|
|
||||||
timeout = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await retryPoll()
|
|
||||||
} catch (err) {
|
|
||||||
// every error should already be handled in retryPoll
|
|
||||||
// but this catch is a safety net to not trigger an unhandled promise rejection
|
|
||||||
console.error('retry poll failed:', err)
|
|
||||||
}
|
|
||||||
if (!stopped) queuePoll()
|
|
||||||
}, NORMAL_POLL_INTERVAL)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopPolling = () => {
|
|
||||||
stopped = true
|
|
||||||
clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
queuePoll()
|
|
||||||
return stopPolling
|
|
||||||
}, [wallets, getFailedInvoices, retry])
|
|
||||||
|
|
||||||
return children
|
|
||||||
}
|
}
|
||||||
|
@ -4,30 +4,23 @@ import { formatSats } from '@/lib/format'
|
|||||||
import useInvoice from '@/components/use-invoice'
|
import useInvoice from '@/components/use-invoice'
|
||||||
import { FAST_POLL_INTERVAL, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants'
|
import { FAST_POLL_INTERVAL, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants'
|
||||||
import {
|
import {
|
||||||
AnonWalletError, WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError,
|
WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError,
|
||||||
WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletReceiverError
|
WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletReceiverError
|
||||||
} from '@/wallets/errors'
|
} from '@/wallets/errors'
|
||||||
import { canSend } from './common'
|
import { canSend } from './common'
|
||||||
import { useWalletLoggerFactory } from './logger'
|
import { useWalletLoggerFactory } from './logger'
|
||||||
import { timeoutSignal, withTimeout } from '@/lib/time'
|
import { timeoutSignal, withTimeout } from '@/lib/time'
|
||||||
import { useMe } from '@/components/me'
|
|
||||||
|
|
||||||
export function useWalletPayment () {
|
export function useWalletPayment () {
|
||||||
const wallets = useSendWallets()
|
const wallets = useSendWallets()
|
||||||
const sendPayment = useSendPayment()
|
const sendPayment = useSendPayment()
|
||||||
const loggerFactory = useWalletLoggerFactory()
|
const loggerFactory = useWalletLoggerFactory()
|
||||||
const invoiceHelper = useInvoice()
|
const invoiceHelper = useInvoice()
|
||||||
const { me } = useMe()
|
|
||||||
|
|
||||||
return useCallback(async (invoice, { waitFor, updateOnFallback } = {}) => {
|
return useCallback(async (invoice, { waitFor, updateOnFallback }) => {
|
||||||
let aggregateError = new WalletAggregateError([])
|
let aggregateError = new WalletAggregateError([])
|
||||||
let latestInvoice = invoice
|
let latestInvoice = invoice
|
||||||
|
|
||||||
// anon user cannot pay with wallets
|
|
||||||
if (!me) {
|
|
||||||
throw new AnonWalletError()
|
|
||||||
}
|
|
||||||
|
|
||||||
// throw a special error that caller can handle separately if no payment was attempted
|
// throw a special error that caller can handle separately if no payment was attempted
|
||||||
if (wallets.length === 0) {
|
if (wallets.length === 0) {
|
||||||
throw new WalletsNotAvailableError()
|
throw new WalletsNotAvailableError()
|
||||||
|
@ -24,13 +24,9 @@ export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]
|
|||||||
|
|
||||||
const MAX_PENDING_INVOICES_PER_WALLET = 25
|
const MAX_PENDING_INVOICES_PER_WALLET = 25
|
||||||
|
|
||||||
export async function * createUserInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { paymentAttempt, predecessorId, models }) {
|
export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { predecessorId, models }) {
|
||||||
// get the wallets in order of priority
|
// get the wallets in order of priority
|
||||||
const wallets = await getInvoiceableWallets(userId, {
|
const wallets = await getInvoiceableWallets(userId, { predecessorId, models })
|
||||||
paymentAttempt,
|
|
||||||
predecessorId,
|
|
||||||
models
|
|
||||||
})
|
|
||||||
|
|
||||||
msats = toPositiveNumber(msats)
|
msats = toPositiveNumber(msats)
|
||||||
|
|
||||||
@ -72,45 +68,47 @@ export async function * createUserInvoice (userId, { msats, description, descrip
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
yield { invoice, wallet, logger }
|
return { invoice, wallet, logger }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('failed to create user invoice:', err)
|
|
||||||
logger.error(err.message, { status: true })
|
logger.error(err.message, { status: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export async function createWrappedInvoice (userId,
|
|
||||||
{ msats, feePercent, description, descriptionHash, expiry = 360 },
|
|
||||||
{ paymentAttempt, predecessorId, models, me, lnd }) {
|
|
||||||
// loop over all receiver wallet invoices until we successfully wrapped one
|
|
||||||
for await (const { invoice, logger, wallet } of createUserInvoice(userId, {
|
|
||||||
// this is the amount the stacker will receive, the other (feePercent)% is our fee
|
|
||||||
msats: toPositiveBigInt(msats) * (100n - feePercent) / 100n,
|
|
||||||
description,
|
|
||||||
descriptionHash,
|
|
||||||
expiry
|
|
||||||
}, { paymentAttempt, predecessorId, models })) {
|
|
||||||
let bolt11
|
|
||||||
try {
|
|
||||||
bolt11 = invoice
|
|
||||||
const { invoice: wrappedInvoice, maxFee } = await wrapInvoice({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd })
|
|
||||||
return {
|
|
||||||
invoice,
|
|
||||||
wrappedInvoice: wrappedInvoice.request,
|
|
||||||
wallet,
|
|
||||||
maxFee
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('failed to wrap invoice:', e)
|
|
||||||
logger?.error('failed to wrap invoice: ' + e.message, { bolt11 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('no wallet to receive available')
|
throw new Error('no wallet to receive available')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInvoiceableWallets (userId, { paymentAttempt, predecessorId, models }) {
|
export async function createWrappedInvoice (userId,
|
||||||
|
{ msats, feePercent, description, descriptionHash, expiry = 360 },
|
||||||
|
{ predecessorId, models, me, lnd }) {
|
||||||
|
let logger, bolt11
|
||||||
|
try {
|
||||||
|
const { invoice, wallet } = await createInvoice(userId, {
|
||||||
|
// this is the amount the stacker will receive, the other (feePercent)% is our fee
|
||||||
|
msats: toPositiveBigInt(msats) * (100n - feePercent) / 100n,
|
||||||
|
description,
|
||||||
|
descriptionHash,
|
||||||
|
expiry
|
||||||
|
}, { predecessorId, models })
|
||||||
|
|
||||||
|
logger = walletLogger({ wallet, models })
|
||||||
|
bolt11 = invoice
|
||||||
|
|
||||||
|
const { invoice: wrappedInvoice, maxFee } =
|
||||||
|
await wrapInvoice({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd })
|
||||||
|
|
||||||
|
return {
|
||||||
|
invoice,
|
||||||
|
wrappedInvoice: wrappedInvoice.request,
|
||||||
|
wallet,
|
||||||
|
maxFee
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger?.error('invalid invoice: ' + e.message, { bolt11 })
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvoiceableWallets (userId, { predecessorId, models }) {
|
||||||
// filter out all wallets that have already been tried by recursively following the retry chain of predecessor invoices.
|
// filter out all wallets that have already been tried by recursively following the retry chain of predecessor invoices.
|
||||||
// the current predecessor invoice is in state 'FAILED' and not in state 'RETRYING' because we are currently retrying it
|
// the current predecessor invoice is in state 'FAILED' and not in state 'RETRYING' because we are currently retrying it
|
||||||
// so it has not been updated yet.
|
// so it has not been updated yet.
|
||||||
@ -143,7 +141,6 @@ export async function getInvoiceableWallets (userId, { paymentAttempt, predecess
|
|||||||
FROM "Invoice"
|
FROM "Invoice"
|
||||||
JOIN "Retries" ON "Invoice"."id" = "Retries"."predecessorId"
|
JOIN "Retries" ON "Invoice"."id" = "Retries"."predecessorId"
|
||||||
WHERE "Invoice"."actionState" = 'RETRYING'
|
WHERE "Invoice"."actionState" = 'RETRYING'
|
||||||
AND "Invoice"."paymentAttempt" = ${paymentAttempt}
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
"InvoiceForward"."walletId"
|
"InvoiceForward"."walletId"
|
||||||
|
@ -1,19 +1,14 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { SSR } from '@/lib/constants'
|
import { SSR } from '@/lib/constants'
|
||||||
import { WalletError } from '../errors'
|
|
||||||
export * from '@/wallets/webln'
|
export * from '@/wallets/webln'
|
||||||
|
|
||||||
export const sendPayment = async (bolt11) => {
|
export const sendPayment = async (bolt11) => {
|
||||||
if (typeof window.webln === 'undefined') {
|
if (typeof window.webln === 'undefined') {
|
||||||
throw new WalletError('WebLN provider not found')
|
throw new Error('WebLN provider not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
// this will prompt the user to unlock the wallet if it's locked
|
// this will prompt the user to unlock the wallet if it's locked
|
||||||
try {
|
await window.webln.enable()
|
||||||
await window.webln.enable()
|
|
||||||
} catch (err) {
|
|
||||||
throw new WalletError(err.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// this will prompt for payment if no budget is set
|
// this will prompt for payment if no budget is set
|
||||||
const response = await window.webln.sendPayment(bolt11)
|
const response = await window.webln.sendPayment(bolt11)
|
||||||
@ -21,7 +16,7 @@ export const sendPayment = async (bolt11) => {
|
|||||||
// sendPayment returns nothing if WebLN was enabled
|
// sendPayment returns nothing if WebLN was enabled
|
||||||
// but browser extension that provides WebLN was then disabled
|
// but browser extension that provides WebLN was then disabled
|
||||||
// without reloading the page
|
// without reloading the page
|
||||||
throw new WalletError('sendPayment returned no response')
|
throw new Error('sendPayment returned no response')
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.preimage
|
return response.preimage
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { msatsSatsFloor, msatsToSats, satsToMsats } from '@/lib/format'
|
import { msatsSatsFloor, msatsToSats, satsToMsats } from '@/lib/format'
|
||||||
import { createWithdrawal } from '@/api/resolvers/wallet'
|
import { createWithdrawal } from '@/api/resolvers/wallet'
|
||||||
import { createUserInvoice } from '@/wallets/server'
|
import { createInvoice } from '@/wallets/server'
|
||||||
|
|
||||||
export async function autoWithdraw ({ data: { id }, models, lnd }) {
|
export async function autoWithdraw ({ data: { id }, models, lnd }) {
|
||||||
const user = await models.user.findUnique({ where: { id } })
|
const user = await models.user.findUnique({ where: { id } })
|
||||||
@ -42,20 +42,14 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
|
|||||||
|
|
||||||
if (pendingOrFailed.exists) return
|
if (pendingOrFailed.exists) return
|
||||||
|
|
||||||
for await (const { invoice, wallet, logger } of createUserInvoice(id, {
|
const { invoice, wallet, logger } = await createInvoice(id, { msats, description: 'SN: autowithdrawal', expiry: 360 }, { models })
|
||||||
msats,
|
|
||||||
description: 'SN: autowithdrawal',
|
|
||||||
expiry: 360
|
|
||||||
}, { models })) {
|
|
||||||
try {
|
|
||||||
return await createWithdrawal(null,
|
|
||||||
{ invoice, maxFee: msatsToSats(maxFeeMsats) },
|
|
||||||
{ me: { id }, models, lnd, wallet, logger })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('failed to create autowithdrawal:', err)
|
|
||||||
logger?.error('incoming payment failed: ' + err.message, { bolt11: invoice })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('no wallet to receive available')
|
try {
|
||||||
|
return await createWithdrawal(null,
|
||||||
|
{ invoice, maxFee: msatsToSats(maxFeeMsats) },
|
||||||
|
{ me: { id }, models, lnd, wallet, logger })
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`incoming payment failed: ${err}`, { bolt11: invoice })
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user