Compare commits

..

No commits in common. "dfe0c4ad231422d7c9783ed85a59e5fc88578544" and "15bd1c3fc5a23767c6aa2d455df7218bce83d6fa" have entirely different histories.

51 changed files with 337 additions and 1582 deletions

View File

@ -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
View File

@ -61,7 +61,4 @@ scripts/nwc-keys.json
docker/lnbits/data
# lndk
!docker/lndk/tls-*.pem
# nostr link extract
scripts/nostr-link-extract.config.json
!docker/lndk/tls-*.pem

View File

@ -31,7 +31,6 @@ Go to [localhost:3000](http://localhost:3000).
- ssh: `git clone git@github.com:stackernews/stacker.news.git`
- https: `git clone https://github.com/stackernews/stacker.news.git`
- 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.
<br>

View File

@ -3,7 +3,7 @@ import { datePivot } from '@/lib/time'
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { createHmac } from '@/api/resolvers/wallet'
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 * as ITEM_CREATE from './itemCreate'
@ -264,51 +264,42 @@ async function performDirectAction (actionType, args, incomingContext) {
throw new NonInvoiceablePeerError()
}
let invoiceObject
try {
await assertBelowMaxPendingDirectPayments(userId, incomingContext)
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
for await (const { invoice, logger, wallet } of createUserInvoice(userId, {
invoiceObject = await createUserInvoice(userId, {
msats: cost,
description,
expiry: INVOICE_EXPIRE_SECS
}, { 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 })
}
}
}, { models, lnd })
} 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) {
@ -428,7 +419,7 @@ async function createSNInvoice (actionType, args, context) {
}
async function createDbInvoice (actionType, args, context) {
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, predecessorId } = context
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs
const db = tx ?? models
@ -454,7 +445,6 @@ async function createDbInvoice (actionType, args, context) {
actionArgs: args,
expiresAt,
actionId,
paymentAttempt,
predecessorId
}

View File

@ -121,39 +121,6 @@ export default {
FROM ${viewGroup(range, 'stacking_growth')}
GROUP BY time
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)
}
}
}

View File

@ -5,7 +5,6 @@ import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
import { replyToSubscription } from '@/lib/webPush'
import { getSub } from './sub'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
export default {
Query: {
@ -346,25 +345,11 @@ export default {
)
queries.push(
`(SELECT "Invoice".id::text,
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
`(SELECT "Invoice".id::text, "Invoice"."updated_at" AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type
FROM "Invoice"
WHERE "Invoice"."userId" = $1
AND "Invoice"."updated_at" < $2
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 (
"Invoice"."actionType" = 'ITEM_CREATE' OR
"Invoice"."actionType" = 'ZAP' OR

View File

@ -1,5 +1,5 @@
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) {
switch (actionType) {
@ -50,32 +50,24 @@ export default {
}
},
Mutation: {
retryPaidAction: async (parent, { invoiceId, newAttempt }, { models, me, lnd }) => {
retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => {
if (!me) {
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.$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 *`
const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me.id } })
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?
const paymentAttempt = newAttempt ? invoice.paymentAttempt + 1 : invoice.paymentAttempt
if (paymentAttempt > WALLET_MAX_RETRIES) {
throw new Error('Payment has been retried too many times')
if (invoice.actionState !== 'FAILED') {
if (invoice.actionState === 'PAID') {
throw new Error('Invoice is already paid')
}
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 {
...result,

View File

@ -4,9 +4,9 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { msatsToSats } from '@/lib/format'
import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate'
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 { datePivot, timeUnitForRange, whenRange } from '@/lib/time'
import { timeUnitForRange, whenRange } from '@/lib/time'
import assertApiKeyNotPermitted from './apiKey'
import { hashEmail } from '@/lib/crypto'
import { isMuted } from '@/lib/user'
@ -543,17 +543,7 @@ export default {
actionType: {
in: INVOICE_ACTION_NOTIFICATION_TYPES
},
actionState: 'FAILED',
OR: [
{
paymentAttempt: {
gte: WALLET_MAX_RETRIES
}
},
{
userCancel: true
}
]
actionState: 'FAILED'
}
})
@ -562,31 +552,6 @@ export default {
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
models.user.update({
where: { id: me.id },

View File

@ -9,10 +9,7 @@ import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib
import {
USER_ID, INVOICE_RETENTION_DAYS,
PAID_ACTION_PAYMENT_METHODS,
WALLET_CREATE_INVOICE_TIMEOUT_MS,
WALLET_RETRY_AFTER_MS,
WALLET_RETRY_BEFORE_MS,
WALLET_MAX_RETRIES
WALLET_CREATE_INVOICE_TIMEOUT_MS
} from '@/lib/constants'
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import assertGofacYourself from './ofac'
@ -459,21 +456,6 @@ const resolvers = {
cursor: nextCursor,
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: {

View File

@ -13,8 +13,6 @@ export default gql`
spenderGrowth(when: String, from: String, to: String): [TimeData!]!
stackingGrowth(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 {

View File

@ -159,7 +159,7 @@ export default gql`
remote: Boolean
sub: Sub
subName: String
status: String!
status: String
uploadId: Int
otsHash: String
parentOtsHash: String

View File

@ -7,7 +7,7 @@ extend type Query {
}
extend type Mutation {
retryPaidAction(invoiceId: Int!, newAttempt: Boolean): PaidAction!
retryPaidAction(invoiceId: Int!): PaidAction!
}
enum PaymentMethod {

View File

@ -31,7 +31,7 @@ export default gql`
}
type Sub {
name: String!
name: ID!
createdAt: Date!
userId: Int!
user: User!

View File

@ -49,7 +49,7 @@ export default gql`
type User {
id: ID!
createdAt: Date!
name: String!
name: String
nitems(when: String, from: String, to: String): Int!
nposts(when: String, from: String, to: String): Int!
nterritories(when: String, from: String, to: String): Int!

View File

@ -72,7 +72,6 @@ const typeDefs = `
wallet(id: ID!): Wallet
walletByType(type: String!): Wallet
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
failedInvoices: [Invoice!]!
}
extend type Mutation {

View File

@ -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
Soxasora,pr,#1814,#1736,easy,,,,100k,soxasora@blink.sv,2025-01-27
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,???,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
174 SatsAllDay issue #1820 #1819 easy 1 9k weareallsatoshi@getalby.com 2025-01-27
175 Soxasora pr #1814 #1736 easy 100k soxasora@blink.sv 2025-01-27
176 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 ??? ???

View File

@ -34,23 +34,20 @@ const setTheme = (dark) => {
const listenForThemeChange = (onChange) => {
const mql = window.matchMedia(PREFER_DARK_QUERY)
const onMqlChange = () => {
mql.onchange = mql => {
const { user, dark } = getTheme()
if (!user) {
handleThemeChange(dark)
onChange({ user, dark })
}
}
mql.addEventListener('change', onMqlChange)
const onStorage = (e) => {
window.onstorage = e => {
if (e.key === STORAGE_KEY) {
const dark = JSON.parse(e.newValue)
setTheme(dark)
onChange({ user: true, dark })
}
}
window.addEventListener('storage', onStorage)
const root = window.document.documentElement
const observer = new window.MutationObserver(() => {
@ -59,11 +56,7 @@ const listenForThemeChange = (onChange) => {
})
observer.observe(root, { attributes: true, attributeFilter: ['data-bs-theme'] })
return () => {
observer.disconnect()
mql.removeEventListener('change', onMqlChange)
window.removeEventListener('storage', onStorage)
}
return () => observer.disconnect()
}
export default function useDarkMode () {
@ -72,7 +65,7 @@ export default function useDarkMode () {
useEffect(() => {
const { user, dark } = getTheme()
setDark({ user, dark })
return listenForThemeChange(setDark)
listenForThemeChange(setDark)
}, [])
return [dark?.dark, () => {

View File

@ -173,7 +173,7 @@ export default function Footer ({ links = true }) {
<Rewards />
</div>
<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
</Link>
<span className='mx-2 text-muted'> \ </span>

View File

@ -20,6 +20,7 @@ import TextareaAutosize from 'react-textarea-autosize'
import { useToast } from './toast'
import { numWithUnits } from '@/lib/format'
import textAreaCaret from 'textarea-caret'
import ReactDatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
import useDebounceCallback, { debounce } from './use-debounce-callback'
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 { useShowModal } from './modal'
import { QRCodeSVG } from 'qrcode.react'
import dynamic from 'next/dynamic'
import { Scanner } from '@yudiel/react-qr-scanner'
import { qrImageSettings } from './qr'
import { useIsClient } from './use-client'
import PageLoading from './page-loading'
export class SessionRequiredError extends Error {
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 }) {
const formik = noForm ? null : useFormikContext()
const [,, fromHelpers] = noForm ? [{}, {}, {}] : useField({ ...props, name: fromName })
@ -1051,23 +1038,19 @@ export function DatePicker ({ fromName, toName, noForm, onChange, when, from, to
}
return (
<>
{ReactDatePicker && (
<ReactDatePicker
className={`form-control text-center ${className}`}
selectsRange
maxDate={new Date()}
minDate={new Date('2021-05-01')}
{...props}
selected={new Date(innerFrom)}
startDate={new Date(innerFrom)}
endDate={innerTo ? new Date(innerTo) : undefined}
dateFormat={dateFormat}
onChangeRaw={onChangeRawHandler}
onChange={innerOnChange}
/>
)}
</>
<ReactDatePicker
className={`form-control text-center ${className}`}
selectsRange
maxDate={new Date()}
minDate={new Date('2021-05-01')}
{...props}
selected={new Date(innerFrom)}
startDate={new Date(innerFrom)}
endDate={innerTo ? new Date(innerTo) : undefined}
dateFormat={dateFormat}
onChangeRaw={onChangeRawHandler}
onChange={innerOnChange}
/>
)
}
@ -1087,27 +1070,19 @@ export function DateTimeInput ({ label, groupClassName, name, ...props }) {
function DateTimePicker ({ name, className, ...props }) {
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 (
<>
{ReactDatePicker && (
<ReactDatePicker
{...field}
{...props}
showTimeSelect
dateFormat='Pp'
className={`form-control ${className}`}
selected={(field.value && new Date(field.value)) || null}
value={(field.value && new Date(field.value)) || null}
onChange={(val) => {
helpers.setValue(val)
}}
/>
)}
</>
<ReactDatePicker
{...field}
{...props}
showTimeSelect
dateFormat='Pp'
className={`form-control ${className}`}
selected={(field.value && new Date(field.value)) || null}
value={(field.value && new Date(field.value)) || null}
onChange={(val) => {
helpers.setValue(val)
}}
/>
)
}
@ -1174,10 +1149,6 @@ function QrPassword ({ value }) {
function PasswordScanner ({ onScan, text }) {
const showModal = useShowModal()
const toaster = useToast()
const Scanner = dynamic(() => import('@yudiel/react-qr-scanner').then(mod => mod.Scanner), {
ssr: false,
loading: () => <PageLoading />
})
return (
<InputGroup.Text
@ -1187,28 +1158,26 @@ function PasswordScanner ({ onScan, text }) {
return (
<div>
{text && <h5 className='line-height-md mb-4 text-center'>{text}</h5>}
{Scanner && (
<Scanner
formats={['qr_code']}
onScan={([{ rawValue: result }]) => {
onScan(result)
onClose()
}}
styles={{
video: {
aspectRatio: '1 / 1'
}
}}
onError={(error) => {
if (error instanceof DOMException) {
console.log(error)
} else {
toaster.danger('qr scan: ' + error?.message || error?.toString?.())
}
onClose()
}}
/>
)}
<Scanner
formats={['qr_code']}
onScan={([{ rawValue: result }]) => {
onScan(result)
onClose()
}}
styles={{
video: {
aspectRatio: '1 / 1'
}
}}
onError={(error) => {
if (error instanceof DOMException) {
console.log(error)
} else {
toaster.danger('qr scan: ' + error?.message || error?.toString?.())
}
onClose()
}}
/>
</div>
)
})

View File

@ -31,7 +31,6 @@
.linkBoxParent input,
.linkBoxParent iframe,
.linkBoxParent video,
.linkBoxParent pre,
.linkBoxParent img {
pointer-events: auto !important;
}
}

View File

@ -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>
)
}

View File

@ -1,7 +1,8 @@
import styles from './text.module.css'
import ReactMarkdown from 'react-markdown'
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 MediaOrLink from './media-or-link'
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 Embed from './embed'
import remarkMath from 'remark-math'
import rehypeMathjax from 'rehype-mathjax'
const rehypeSNStyled = () => rehypeSN({
stylers: [{
@ -34,6 +36,7 @@ const rehypeSNStyled = () => rehypeSN({
})
const remarkPlugins = [gfm, remarkUnicode, [remarkMath, { singleDollarTextMath: false }]]
const rehypePlugins = [rehypeSNStyled, rehypeMathjax]
export function SearchText ({ text }) {
return (
@ -49,32 +52,16 @@ export function SearchText ({ text }) {
// this is one of the slowest components to render
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)
// should we show the full text?
const router = useRouter()
const [show, setShow] = useState(false)
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
useEffect(() => {
setShow(router.asPath.includes('#'))
setShow(router.asPath.includes('#') && !router.asPath.includes('#itemfn-'))
const handleRouteChange = (url, { shallow }) => {
setShow(url.includes('#'))
setShow(url.includes('#') && !url.includes('#itemfn-'))
}
router.events.on('hashChangeStart', handleRouteChange)
@ -147,12 +134,12 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child
<ReactMarkdown
components={components}
remarkPlugins={remarkPlugins}
rehypePlugins={[rehypeSNStyled, mathJaxPlugin].filter(Boolean)}
rehypePlugins={rehypePlugins}
remarkRehypeOptions={{ clobberPrefix: `itemfn-${itemId}-` }}
>
{children}
</ReactMarkdown>
), [components, remarkPlugins, mathJaxPlugin, children, itemId])
), [components, remarkPlugins, rehypePlugins, children, itemId])
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 }) {
const [ReactSyntaxHighlighter, setReactSyntaxHighlighter] = useState(null)
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 (
return inline
? (
<code className={className} {...props}>
{children}
</code>
)
}
return (
<>
{ReactSyntaxHighlighter && syntaxTheme && (
<ReactSyntaxHighlighter style={syntaxTheme} language={language} PreTag='div' customStyle={{ borderRadius: '0.3rem' }} {...props}>
{children}
</ReactSyntaxHighlighter>
)}
</>
)
)
: (
<SyntaxHighlighter style={atomDark} language='text' PreTag='div' {...props}>
{children}
</SyntaxHighlighter>
)
}
function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...props }) {

View File

@ -3,10 +3,10 @@ import { Select, DatePicker } from './form'
import { WHENS } from '@/lib/constants'
import { whenToFrom } from '@/lib/time'
export function UserAnalyticsHeader ({ pathname = null }) {
export function UsageHeader ({ pathname = null }) {
const router = useRouter()
const path = pathname || 'satistics/graph'
const path = pathname || 'stackers'
const select = async values => {
const { when, ...query } = values

View File

@ -1,5 +1,5 @@
import { useApolloClient, useMutation } from '@apollo/client'
import { useCallback, useMemo } from 'react'
import { useCallback } from 'react'
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors'
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet'
@ -42,9 +42,9 @@ export default function useInvoice () {
return data.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)
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
const newInvoice = data.retryPaidAction.invoice
@ -53,5 +53,5 @@ export default function useInvoice () {
return newInvoice
}, [retryPaidAction])
return useMemo(() => ({ cancel, retry, isInvoice }), [cancel, retry, isInvoice])
return { cancel, retry, isInvoice }
}

View File

@ -1,9 +1,8 @@
import { useCallback } from 'react'
import Invoice from '@/components/invoice'
import { InvoiceCanceledError, InvoiceExpiredError, AnonWalletError } from '@/wallets/errors'
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
import { useShowModal } from '@/components/modal'
import useInvoice from '@/components/use-invoice'
import { sendPayment } from '@/wallets/webln/client'
export default function useQrPayment () {
const invoice = useInvoice()
@ -17,10 +16,6 @@ export default function useQrPayment () {
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) => {
let paid
const cancelAndReject = async (onClose) => {

View File

@ -10,6 +10,4 @@ mz
btcbagehot
felipe
benalleng
rblb
Scroogey
SimpleStacker
rblb

View File

@ -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).

View File

@ -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'])

View File

@ -91,8 +91,8 @@ export const RETRY_PAID_ACTION = gql`
${PAID_ACTION}
${ITEM_PAID_ACTION_FIELDS}
${ITEM_ACT_PAID_ACTION_FIELDS}
mutation retryPaidAction($invoiceId: Int!, $newAttempt: Boolean) {
retryPaidAction(invoiceId: $invoiceId, newAttempt: $newAttempt) {
mutation retryPaidAction($invoiceId: Int!) {
retryPaidAction(invoiceId: $invoiceId) {
__typename
...PaidActionFields
... on ItemPaidAction {

View File

@ -231,12 +231,3 @@ export const CANCEL_INVOICE = gql`
}
}
`
export const FAILED_INVOICES = gql`
${INVOICE_FIELDS}
query FailedInvoices {
failedInvoices {
...InvoiceFields
}
}
`

View File

@ -283,12 +283,6 @@ function getClient (uri) {
facts: [...(existing?.facts || []), ...incoming.facts]
}
}
},
failedInvoices: {
keyArgs: [],
merge (existing, incoming) {
return incoming
}
}
}
},

View File

@ -2,7 +2,6 @@
// to be loaded from the server
export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', '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 = {
FEE_CREDIT: 'FEE_CREDIT',
@ -13,7 +12,7 @@ export const PAID_ACTION_PAYMENT_METHODS = {
REWARD_SATS: 'REWARD_SATS'
}
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 UPLOAD_SIZE_MAX = 50 * 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_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'

View File

@ -59,10 +59,10 @@ export default function rehypeSN (options = {}) {
if (node.properties.href.includes('#itemfn-')) {
node.tagName = 'footnote'
} else {
const { itemId, commentId, linkText } = parseInternalLinks(node.properties.href)
if (itemId || commentId) {
const { itemId, linkText } = parseInternalLinks(node.properties.href)
if (itemId) {
node.tagName = 'item'
node.properties.id = commentId || itemId
node.properties.id = itemId
if (node.properties.href === toString(node)) {
node.children[0].value = linkText
}

View File

@ -93,7 +93,7 @@ export function parseEmbedUrl (href) {
const { hostname, pathname, searchParams } = new URL(href)
// 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) {
let id = nostr.groups.id
if (nostr.groups.type === 'npub1') {

View File

@ -2,8 +2,7 @@ import { string, ValidationError, number, object, array, boolean, date } from '.
import {
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,
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX,
RESERVED_SUB_NAMES
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX
} from './constants'
import { SUPPORTED_CURRENCIES } from './currency'
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 filter = sub => editing ? !isEdit(sub) : !isArchived(sub)
const exists = await subExists(name, { ...args, filter })
return !exists & !RESERVED_SUB_NAMES.includes(name)
return !exists
},
message: 'taken'
}),

View File

@ -189,11 +189,6 @@ module.exports = withPlausibleProxy()({
source: '/statistics',
destination: '/satistics?inc=invoice,withdrawal',
permanent: true
},
{
source: '/top/cowboys/:when',
destination: '/top/cowboys',
permanent: true
}
]
},

View File

@ -22,7 +22,7 @@ export default function Email () {
const params = new URLSearchParams()
if (callback.callbackUrl) params.set('callbackUrl', callback.callbackUrl)
params.set('token', token)
params.set('email', callback.email.toLowerCase())
params.set('email', callback.email)
const url = `/api/auth/callback/email?${params.toString()}`
router.push(url)
}, [callback, router])

View File

@ -6,7 +6,7 @@ import { useRouter } from 'next/router'
import PageLoading from '@/components/page-loading'
import dynamic from 'next/dynamic'
import { numWithUnits } from '@/lib/format'
import { UserAnalyticsHeader } from '@/components/user-analytics-header'
import { UsageHeader } from '@/components/usage-header'
import { SatisticsHeader } from '..'
import { WhenComposedChartSkeleton, WhenAreaChartSkeleton } from '@/components/charts-skeletons'
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
@ -55,7 +55,7 @@ export default function Satistics ({ ssrData }) {
<SatisticsHeader />
<div className='tab-content' id='myTabContent'>
<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='d-flex row justify-content-between'>
<div className='col-md-6 mb-2'>

View File

@ -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
View 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>
)
}

View File

@ -15,6 +15,7 @@ import { lnAddrSchema, withdrawlSchema } from '@/lib/validate'
import { useShowModal } from '@/components/modal'
import { useField } from 'formik'
import { useToast } from '@/components/toast'
import { Scanner } from '@yudiel/react-qr-scanner'
import { decode } from 'bolt11'
import CameraIcon from '@/svgs/camera-line.svg'
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 AccordianItem from '@/components/accordian-item'
import { numWithUnits } from '@/lib/format'
import PageLoading from '@/components/page-loading'
import dynamic from 'next/dynamic'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
@ -154,47 +153,39 @@ function InvoiceScanner ({ fieldName }) {
const showModal = useShowModal()
const [,, helpers] = useField(fieldName)
const toaster = useToast()
const Scanner = dynamic(() => import('@yudiel/react-qr-scanner').then(mod => mod.Scanner), {
ssr: false,
loading: () => <PageLoading />
})
return (
<InputGroup.Text
style={{ cursor: 'pointer' }}
onClick={() => {
showModal(onClose => {
return (
<>
{Scanner && (
<Scanner
formats={['qr_code']}
onScan={([{ rawValue: result }]) => {
result = result.toLowerCase()
if (result.split('lightning=')[1]) {
helpers.setValue(result.split('lightning=')[1].split(/[&?]/)[0])
} else if (decode(result.replace(/^lightning:/, ''))) {
helpers.setValue(result.replace(/^lightning:/, ''))
} else {
throw new Error('Not a proper lightning payment request')
}
onClose()
}}
styles={{
video: {
aspectRatio: '1 / 1'
}
}}
onError={(error) => {
if (error instanceof DOMException) {
console.log(error)
} else {
toaster.danger('qr scan: ' + error?.message || error?.toString?.())
}
onClose()
}}
/>)}
</>
<Scanner
formats={['qr_code']}
onScan={([{ rawValue: result }]) => {
result = result.toLowerCase()
if (result.split('lightning=')[1]) {
helpers.setValue(result.split('lightning=')[1].split(/[&?]/)[0])
} else if (decode(result.replace(/^lightning:/, ''))) {
helpers.setValue(result.replace(/^lightning:/, ''))
} else {
throw new Error('Not a proper lightning payment request')
}
onClose()
}}
styles={{
video: {
aspectRatio: '1 / 1'
}
}}
onError={(error) => {
if (error instanceof DOMException) {
console.log(error)
} else {
toaster.danger('qr scan: ' + error?.message || error?.toString?.())
}
onClose()
}}
/>
)
})
}}

View File

@ -6,7 +6,6 @@ import UserList from '@/components/user-list'
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 }) {
return (
<Layout>

View File

@ -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");

View File

@ -928,8 +928,6 @@ model Invoice {
cancelled Boolean @default(false)
cancelledAt DateTime?
userCancel Boolean?
paymentAttempt Int @default(0)
retryPendingSince DateTime?
msatsRequested BigInt
msatsReceived BigInt?
desc String?
@ -958,7 +956,6 @@ model Invoice {
@@index([confirmedIndex], map: "Invoice.confirmedIndex_index")
@@index([isHeld])
@@index([confirmedAt])
@@index([cancelledAt])
@@index([actionType])
@@index([actionState])
}

View File

@ -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()

View File

@ -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 {
constructor (errors, invoice) {
super('WalletAggregateError')

View File

@ -1,15 +1,12 @@
import { useMe } from '@/components/me'
import { FAILED_INVOICES, SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { useApolloClient, useLazyQuery, useMutation, useQuery } from '@apollo/client'
import { SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet'
import { SSR } from '@/lib/constants'
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
import useVault from '@/components/vault/use-vault'
import walletDefs from '@/wallets/client'
import { generateMutation } from './graphql'
import { useWalletPayment } from './payment'
import useInvoice from '@/components/use-invoice'
import { WalletConfigurationError } from './errors'
const WalletsContext = createContext({
wallets: []
@ -207,9 +204,7 @@ export function WalletsProvider ({ children }) {
removeLocalWallets
}}
>
<RetryHandler>
{children}
</RetryHandler>
{children}
</WalletsContext.Provider>
)
}
@ -226,79 +221,7 @@ export function useWallet (name) {
export function useSendWallets () {
const { wallets } = useWallets()
// 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.config?.enabled && canSend(w)), [wallets])
}
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
.filter(w => w.config?.enabled && canSend(w))
}

View File

@ -4,30 +4,23 @@ import { formatSats } from '@/lib/format'
import useInvoice from '@/components/use-invoice'
import { FAST_POLL_INTERVAL, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants'
import {
AnonWalletError, WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError,
WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError,
WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletReceiverError
} from '@/wallets/errors'
import { canSend } from './common'
import { useWalletLoggerFactory } from './logger'
import { timeoutSignal, withTimeout } from '@/lib/time'
import { useMe } from '@/components/me'
export function useWalletPayment () {
const wallets = useSendWallets()
const sendPayment = useSendPayment()
const loggerFactory = useWalletLoggerFactory()
const invoiceHelper = useInvoice()
const { me } = useMe()
return useCallback(async (invoice, { waitFor, updateOnFallback } = {}) => {
return useCallback(async (invoice, { waitFor, updateOnFallback }) => {
let aggregateError = new WalletAggregateError([])
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
if (wallets.length === 0) {
throw new WalletsNotAvailableError()

View File

@ -24,13 +24,9 @@ export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]
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
const wallets = await getInvoiceableWallets(userId, {
paymentAttempt,
predecessorId,
models
})
const wallets = await getInvoiceableWallets(userId, { predecessorId, models })
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) {
console.error('failed to create user invoice:', err)
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')
}
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.
// 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.
@ -143,7 +141,6 @@ export async function getInvoiceableWallets (userId, { paymentAttempt, predecess
FROM "Invoice"
JOIN "Retries" ON "Invoice"."id" = "Retries"."predecessorId"
WHERE "Invoice"."actionState" = 'RETRYING'
AND "Invoice"."paymentAttempt" = ${paymentAttempt}
)
SELECT
"InvoiceForward"."walletId"

View File

@ -1,19 +1,14 @@
import { useEffect } from 'react'
import { SSR } from '@/lib/constants'
import { WalletError } from '../errors'
export * from '@/wallets/webln'
export const sendPayment = async (bolt11) => {
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
try {
await window.webln.enable()
} catch (err) {
throw new WalletError(err.message)
}
await window.webln.enable()
// this will prompt for payment if no budget is set
const response = await window.webln.sendPayment(bolt11)
@ -21,7 +16,7 @@ export const sendPayment = async (bolt11) => {
// sendPayment returns nothing if WebLN was enabled
// but browser extension that provides WebLN was then disabled
// without reloading the page
throw new WalletError('sendPayment returned no response')
throw new Error('sendPayment returned no response')
}
return response.preimage

View File

@ -1,6 +1,6 @@
import { msatsSatsFloor, msatsToSats, satsToMsats } from '@/lib/format'
import { createWithdrawal } from '@/api/resolvers/wallet'
import { createUserInvoice } from '@/wallets/server'
import { createInvoice } from '@/wallets/server'
export async function autoWithdraw ({ data: { id }, models, lnd }) {
const user = await models.user.findUnique({ where: { id } })
@ -42,20 +42,14 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
if (pendingOrFailed.exists) return
for await (const { invoice, wallet, logger } of createUserInvoice(id, {
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 })
}
}
const { invoice, wallet, logger } = await createInvoice(id, { msats, description: 'SN: autowithdrawal', expiry: 360 }, { models })
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
}
}