Compare commits

..

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

188 changed files with 2018 additions and 4184 deletions

View File

@ -1,21 +0,0 @@
{
"name": "sndev",
"hostRequirements": {
"cpus": 4,
"memory": "16gb",
"storage": "32gb"
},
"customizations": {
"vscode": {
"extensions": [
"ms-azuretools.vscode-containers"
]
}
},
"containerEnv": {
"CPU_SHARES_IMPORTANT": "1024",
"CPU_SHARES_MODERATE": "512",
"CPU_SHARES_LOW": "128"
},
"postAttachCommand": "./scripts/setup-codespaces.sh && source .env.local && ./sndev start"
}

View File

@ -1,22 +0,0 @@
{
"name": "sndev MINIMAL",
"hostRequirements": {
"cpus": 4,
"memory": "16gb",
"storage": "32gb"
},
"customizations": {
"vscode": {
"extensions": [
"ms-azuretools.vscode-containers"
]
}
},
"containerEnv": {
"CPU_SHARES_IMPORTANT": "1024",
"CPU_SHARES_MODERATE": "512",
"CPU_SHARES_LOW": "128",
"COMPOSE_PROFILES": "minimal"
},
"postAttachCommand": "./scripts/setup-codespaces.sh && source .env.local && ./sndev start"
}

View File

@ -107,10 +107,10 @@ DB_WORKER_CONNECTION_LIMIT=2
DB_TRANSACTION_TIMEOUT=5000 DB_TRANSACTION_TIMEOUT=5000
# polling intervals # polling intervals
NEXT_PUBLIC_FAST_POLL_INTERVAL_MS=1000 NEXT_PUBLIC_FAST_POLL_INTERVAL=1000
NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS=30000 NEXT_PUBLIC_NORMAL_POLL_INTERVAL=30000
NEXT_PUBLIC_LONG_POLL_INTERVAL_MS=60000 NEXT_PUBLIC_LONG_POLL_INTERVAL=60000
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS=300000 NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
# containers can't use localhost, so we need to use the container name # containers can't use localhost, so we need to use the container name
IMGPROXY_URL_DOCKER=http://imgproxy:8080 IMGPROXY_URL_DOCKER=http://imgproxy:8080
@ -140,8 +140,8 @@ SN_LND_REST_PORT=8080
SN_LND_GRPC_PORT=10009 SN_LND_GRPC_PORT=10009
SN_LND_P2P_PORT=9735 SN_LND_P2P_PORT=9735
# docker exec -u lnd sn_lnd lncli newaddress p2wkh --unused # docker exec -u lnd sn_lnd lncli newaddress p2wkh --unused
SN_LND_PUBKEY=034fcbe80658a2b0e32d416ca34c91cf359f7010b3529582fce6b1deddfadb2ba6 SN_LND_PUBKEY=03dc0de8fbe29ef3d26554c615adfd17aaca959403c4e9ecebaac4b83978d86342
SN_LND_ADDR=bcrt1qq6m9009pl3nkhku5905hl8nktq8zggfa75tfcy SN_LND_ADDR=bcrt1qu6g49vrl8n4ay99hr04wefkfy2e8g0z4nc0sjw
# sn_lndk stuff # sn_lndk stuff
SN_LNDK_GRPC_PORT=10012 SN_LNDK_GRPC_PORT=10012
@ -149,8 +149,8 @@ SN_LNDK_GRPC_PORT=10012
LND_REST_PORT=8081 LND_REST_PORT=8081
LND_GRPC_PORT=10010 LND_GRPC_PORT=10010
# docker exec -u lnd lnd lncli newaddress p2wkh --unused # docker exec -u lnd lnd lncli newaddress p2wkh --unused
LND_ADDR=bcrt1qvqjlqw3zmpqh9cv2jr20n2qn7u4n6k7f74yw4p LND_ADDR=bcrt1qfqau4ug9e6rtrvxrgclg58e0r93wshucumm9vu
LND_PUBKEY=03eff113993bd7dbb43f5e923d5569ecefa43e3cc7ce5b5634a4b6854d4b287dfa LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35
# cln container stuff # cln container stuff
CLN_REST_PORT=9092 CLN_REST_PORT=9092
@ -167,8 +167,8 @@ ECLAIR_PUBKEY="02268c74cc07837041131474881f97d497706b89a29f939555da6d094b65bd5af
ROUTER_LND_REST_PORT=8082 ROUTER_LND_REST_PORT=8082
ROUTER_LND_GRPC_PORT=10011 ROUTER_LND_GRPC_PORT=10011
# docker exec -u lnd router_lnd lncli newaddress p2wkh --unused # docker exec -u lnd router_lnd lncli newaddress p2wkh --unused
ROUTER_LND_ADDR=bcrt1qx7envhw8q48huaspl2am9nack3ql0fnyvy7ygy ROUTER_LND_ADDR=bcrt1qfkmwfpwgn6wt0dd36s79x04swz8vleyafsdpdr
ROUTER_LND_PUBKEY=02862c14ccfc566234fd2ccb61f6f134ba4fa06cd13272d47a18bf4579c2df3cf4 ROUTER_LND_PUBKEY=02750991fbf62e57631888bc469fae69c5e658bd1d245d8ab95ed883517caa33c3
LNCLI_NETWORK=regtest LNCLI_NETWORK=regtest

View File

@ -11,10 +11,10 @@ NEXT_PUBLIC_MEDIA_DOMAIN=m.stacker.news
PUBLIC_URL=https://stacker.news PUBLIC_URL=https://stacker.news
SELF_URL=http://127.0.0.1:8080 SELF_URL=http://127.0.0.1:8080
grpc_proxy=http://127.0.0.1:7050 grpc_proxy=http://127.0.0.1:7050
NEXT_PUBLIC_FAST_POLL_INTERVAL_MS=1000 NEXT_PUBLIC_FAST_POLL_INTERVAL=1000
NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS=30000 NEXT_PUBLIC_NORMAL_POLL_INTERVAL=30000
NEXT_PUBLIC_LONG_POLL_INTERVAL_MS=60000 NEXT_PUBLIC_LONG_POLL_INTERVAL=60000
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS=300000 NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
NEXT_PUBLIC_URL=https://stacker.news NEXT_PUBLIC_URL=https://stacker.news
TOR_PROXY=http://127.0.0.1:7050/ TOR_PROXY=http://127.0.0.1:7050/
PRISMA_SLOW_LOGS_MS=50 PRISMA_SLOW_LOGS_MS=50

View File

@ -21,6 +21,3 @@ _For example, a change is not backward compatible if you removed a GraphQL field
**Did you introduce any new environment variables? If so, call them out explicitly here:** **Did you introduce any new environment variables? If so, call them out explicitly here:**
**Did you use AI for this? If so, how much did it assist you?**

View File

@ -16,6 +16,4 @@ EXPOSE 3000
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps --loglevel verbose RUN npm ci --legacy-peer-deps --loglevel verbose
CMD ["sh","-c","npm install --loglevel verbose --legacy-peer-deps && npx prisma migrate dev && npm run dev"]
# run npm ci again because we're mounting node_modules in local dev
CMD ["sh","-c","npm ci --legacy-peer-deps --loglevel verbose && npx prisma migrate dev && npm run dev"]

View File

@ -36,34 +36,6 @@ Go to [localhost:3000](http://localhost:3000).
<br> <br>
### GitHub Codespaces
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/stackernews/stacker.news)
You can run Stacker News on Github Codespaces
#### Setup
1. Open the repository on GitHub and click the **"Code"** button
2. Select the Codespaces tab and create a new codespace.
- You can also configure your codespace to run select services based on `COMPOSE_PROFILES` as well as in a different region and machine type by clicking "..." and selecting "New with options...". Check [Modifying services](#modifying-services) for more information on `COMPOSE_PROFILES`
3. Wait for the environment to set up (this may take several minutes the first time)
4. Once ready, you'll see a terminal with the environment initialized
#### Usage
After the codespace is created, the development environment will be automatically set up and services started.
Access your running application at the URL shown in the forwarded ports panel (typically `https://your-codespace-name-3000.app.github.dev`).
#### Port Configuration
⚠️ **Important**: For various internal services and external access to work properly, you must set forwarded ports to **Public** in the Ports tab:
1. In your codespace, look for the "PORTS" tab in the bottom panel
2. Click the lock icon to change visibility from "Private" to "Public"
<br>
## Usage ## Usage
Start the development environment Start the development environment
@ -198,7 +170,6 @@ To add/remove DNS records you can now use `./sndev domains dns`. More on this [h
# Table of Contents # Table of Contents
- [Getting started](#getting-started) - [Getting started](#getting-started)
- [Installation](#installation) - [Installation](#installation)
- [GitHub Codespaces](#github-codespaces)
- [Usage](#usage) - [Usage](#usage)
- [Modifying services](#modifying-services) - [Modifying services](#modifying-services)
- [Running specific services](#running-specific-services) - [Running specific services](#running-specific-services)

View File

@ -60,11 +60,17 @@ export async function estimateRouteFee ({ lnd, destination, tokens, mtokens, req
timeout timeout
}, (err, res) => { }, (err, res) => {
if (err) { if (err) {
return reject(err) if (res?.failure_reason) {
reject(new Error(`Unable to estimate route: ${res.failure_reason}`))
} else {
reject(err)
}
return
} }
if (res.failure_reason !== 'FAILURE_REASON_NONE' || res.routing_fee_msat < 0 || res.time_lock_delay <= 0) { if (res.routing_fee_msat < 0 || res.time_lock_delay <= 0) {
return reject(new Error(`Unable to estimate route: ${res.failure_reason}`)) reject(new Error('Unable to estimate route, excessive values: ' + JSON.stringify(res)))
return
} }
resolve({ resolve({

View File

@ -49,12 +49,10 @@ export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio },
FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[])) FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ ${satsToMsats(boost)}::INTEGER as cost` + ${satsToMsats(boost)}::INTEGER as cost`
// freebies currently only apply to bios (comments disabled). To keep togglable behavior, // sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon,
// respect user's disableFreebies preference for bios as well. // cost must be greater than user's balance, and user has not disabled freebies
// conditions: is a bio, cost <= baseCost, user logged in, insufficient balance & credits, const freebie = (parentId || bio) && cost <= baseCost && !!me &&
// and user has not disabled freebies me?.msats < cost && !me?.disableFreebies && me?.mcredits < cost
const freebie = bio && cost <= baseCost && !!me &&
me?.msats < cost && me?.mcredits < cost && !me?.disableFreebies
return freebie ? BigInt(0) : BigInt(cost) return freebie ? BigInt(0) : BigInt(cost)
} }

View File

@ -1,5 +1,5 @@
import { ensureProtocol, removeTracking, stripTrailingSlash } from '@/lib/url' import { ensureProtocol, removeTracking, stripTrailingSlash } from '@/lib/url'
import { decodeCursor, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import { ruleSet as publicationDateRuleSet } from '@/lib/timedate-scraper' import { ruleSet as publicationDateRuleSet } from '@/lib/timedate-scraper'
import domino from 'domino' import domino from 'domino'
@ -54,8 +54,7 @@ function commentsOrderByClause (me, models, sort) {
async function comments (me, models, item, sort, cursor) { async function comments (me, models, item, sort, cursor) {
const orderBy = commentsOrderByClause(me, models, sort) const orderBy = commentsOrderByClause(me, models, sort)
// if we're logged in, there might be pending comments from us we want to show but weren't counted if (item.nDirectComments === 0) {
if (!me && item.nDirectComments === 0) {
return { return {
comments: [], comments: [],
cursor: null cursor: null
@ -68,7 +67,7 @@ async function comments (me, models, item, sort, cursor) {
// XXX what a mess // XXX what a mess
let comments let comments
if (me) { if (me) {
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id}) AND ("Item"."parentId" <> $1 OR "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3)) ` const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id}) AND "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3) `
if (item.ncomments > FULL_COMMENTS_THRESHOLD) { if (item.ncomments > FULL_COMMENTS_THRESHOLD) {
const [{ item_comments_zaprank_with_me_limited: limitedComments }] = await models.$queryRawUnsafe( const [{ item_comments_zaprank_with_me_limited: limitedComments }] = await models.$queryRawUnsafe(
'SELECT item_comments_zaprank_with_me_limited($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8, $9)', 'SELECT item_comments_zaprank_with_me_limited($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8, $9)',
@ -81,7 +80,7 @@ async function comments (me, models, item, sort, cursor) {
comments = fullComments comments = fullComments
} }
} else { } else {
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID') AND ("Item"."parentId" <> $1 OR "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3)) ` const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID') AND "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3) `
if (item.ncomments > FULL_COMMENTS_THRESHOLD) { if (item.ncomments > FULL_COMMENTS_THRESHOLD) {
const [{ item_comments_limited: limitedComments }] = await models.$queryRawUnsafe( const [{ item_comments_limited: limitedComments }] = await models.$queryRawUnsafe(
'SELECT item_comments_limited($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5::INTEGER, $6, $7)', 'SELECT item_comments_limited($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5::INTEGER, $6, $7)',
@ -130,7 +129,6 @@ export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { m
'"Item"."parentId" IS NULL', '"Item"."parentId" IS NULL',
'"Item".bio = false', '"Item".bio = false',
'"Item".boost > 0', '"Item".boost > 0',
await filterClause(me, models),
activeOrMine(), activeOrMine(),
subClause(sub, 1, 'Item', me, showNsfw), subClause(sub, 1, 'Item', me, showNsfw),
muteClause(me))} muteClause(me))}
@ -181,8 +179,7 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark", COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark",
"ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward", "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward",
to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL) to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL)
|| jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub, || jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub
"CommentsViewAt"."last_viewed_at" as "meCommentsViewedAt"
FROM ( FROM (
${query} ${query}
) "Item" ) "Item"
@ -194,7 +191,6 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id} LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}
LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id} LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id}
LEFT JOIN "CommentsViewAt" ON "CommentsViewAt"."itemId" = "Item".id AND "CommentsViewAt"."userId" = ${me.id}
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT "itemId", SELECT "itemId",
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND "InvoiceForward".id IS NOT NULL AND (act = 'FEE' OR act = 'TIP')) AS "meMsats", sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND "InvoiceForward".id IS NOT NULL AND (act = 'FEE' OR act = 'TIP')) AS "meMsats",
@ -365,7 +361,7 @@ export default {
return count return count
}, },
items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit }, { me, models }) => { items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit = LIMIT }, { me, models }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
let items, user, pins, subFull, table, ad let items, user, pins, subFull, table, ad
@ -586,7 +582,7 @@ export default {
break break
} }
return { return {
cursor: items.length === limit ? nextCursorEncoded(decodedCursor, limit) : null, cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
items, items,
pins, pins,
ad ad
@ -744,7 +740,7 @@ export default {
subMaxBoost: subAgg?._max.boost || 0 subMaxBoost: subAgg?._max.boost || 0
} }
}, },
newComments: async (parent, { itemId, after }, { models, me }) => { newComments: async (parent, { rootId, after }, { models, me }) => {
const comments = await itemQueryWithMeta({ const comments = await itemQueryWithMeta({
me, me,
models, models,
@ -758,7 +754,7 @@ export default {
'"Item"."created_at" > $2' '"Item"."created_at" > $2'
)} )}
ORDER BY "Item"."created_at" ASC` ORDER BY "Item"."created_at" ASC`
}, Number(itemId), after) }, Number(rootId), after)
return { comments } return { comments }
} }
@ -1076,21 +1072,6 @@ export default {
]) ])
return result return result
},
updateCommentsViewAt: async (parent, { id, meCommentsViewedAt }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const result = await models.commentsViewAt.upsert({
where: {
userId_itemId: { userId: Number(me.id), itemId: Number(id) }
},
update: { lastViewedAt: new Date(meCommentsViewedAt) },
create: { userId: Number(me.id), itemId: Number(id), lastViewedAt: new Date(meCommentsViewedAt) }
})
return result.lastViewedAt
} }
}, },
ItemAct: { ItemAct: {
@ -1241,8 +1222,7 @@ export default {
return item.comments return item.comments
} }
// if we're logged in, there might be pending comments from us we want to show but weren't counted if (item.ncomments === 0) {
if (!me && item.ncomments === 0) {
return { return {
comments: [], comments: [],
cursor: null cursor: null

View File

@ -167,7 +167,6 @@ export default {
) as "Item" ) as "Item"
${whereClause( ${whereClause(
'"Item".created_at < $2', '"Item".created_at < $2',
'"Item"."deletedAt" IS NULL',
await filterClause(me, models), await filterClause(me, models),
muteClause(me), muteClause(me),
activeOrMine(me))} activeOrMine(me))}

View File

@ -2,7 +2,6 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { whenToFrom } from '@/lib/time' import { whenToFrom } from '@/lib/time'
import { getItem, itemQueryWithMeta, SELECT } from './item' import { getItem, itemQueryWithMeta, SELECT } from './item'
import { parse } from 'tldts' import { parse } from 'tldts'
import { searchSchema, validateSchema } from '@/lib/validate'
function queryParts (q) { function queryParts (q) {
const regex = /"([^"]*)"/gm const regex = /"([^"]*)"/gm
@ -25,7 +24,7 @@ function queryParts (q) {
export default { export default {
Query: { Query: {
related: async (parent, { title, id, cursor, limit, minMatch }, { me, models, search }) => { related: async (parent, { title, id, cursor, limit = LIMIT, minMatch }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
if (!id && (!title || title.trim().split(/\s+/).length < 1)) { if (!id && (!title || title.trim().split(/\s+/).length < 1)) {
@ -81,7 +80,7 @@ export default {
{ {
neural: { neural: {
title_embedding: { title_embedding: {
query_text: qtitle, query_text: qtext,
model_id: process.env.OPENSEARCH_MODEL_ID, model_id: process.env.OPENSEARCH_MODEL_ID,
k: decodedCursor.offset + LIMIT k: decodedCursor.offset + LIMIT
} }
@ -90,7 +89,7 @@ export default {
{ {
neural: { neural: {
text_embedding: { text_embedding: {
query_text: qtext.slice(0, 100), query_text: qtitle,
model_id: process.env.OPENSEARCH_MODEL_ID, model_id: process.env.OPENSEARCH_MODEL_ID,
k: decodedCursor.offset + LIMIT k: decodedCursor.offset + LIMIT
} }
@ -169,12 +168,11 @@ export default {
}) })
return { return {
cursor: items.length === limit ? nextCursorEncoded(decodedCursor, limit) : null, cursor: items.length === (limit || LIMIT) ? nextCursorEncoded(decodedCursor) : null,
items items
} }
}, },
search: async (parent, { q, cursor, sort, what, when, from: whenFrom, to: whenTo }, { me, models, search }) => { search: async (parent, { q, cursor, sort, what, when, from: whenFrom, to: whenTo }, { me, models, search }) => {
await validateSchema(searchSchema, { q })
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
let sitems = null let sitems = null

View File

@ -6,7 +6,6 @@ import { notifyTerritoryTransfer } from '@/lib/webPush'
import performPaidAction from '../paidAction' import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error' import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { uploadIdsFromText } from './upload' import { uploadIdsFromText } from './upload'
import { Prisma } from '@prisma/client'
export async function getSub (parent, { name }, { models, me }) { export async function getSub (parent, { name }, { models, me }) {
if (!name) return null if (!name) return null
@ -37,15 +36,24 @@ export async function getSub (parent, { name }, { models, me }) {
export default { export default {
Query: { Query: {
sub: getSub, sub: getSub,
subSuggestions: async (parent, { q, limit }, { models }) => { subSuggestions: async (parent, { q, limit = 5 }, { models }) => {
let subs = [] let subs = []
subs = await models.$queryRaw` if (q) {
subs = await models.$queryRaw`
SELECT name SELECT name
FROM "Sub" FROM "Sub"
WHERE status IN ('ACTIVE', 'GRACE') WHERE status = 'ACTIVE'
${q ? Prisma.sql`AND SIMILARITY(name, ${q}) > 0.1` : Prisma.empty} AND SIMILARITY(name, ${q}) > 0.1
${q ? Prisma.sql`ORDER BY SIMILARITY(name, ${q}) DESC` : Prisma.sql`ORDER BY name ASC`} ORDER BY SIMILARITY(name, ${q}) DESC
LIMIT ${limit}` LIMIT ${limit}`
} else {
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status = 'ACTIVE'
ORDER BY name ASC
LIMIT ${limit}`
}
return subs return subs
}, },
@ -54,15 +62,14 @@ export default {
const currentUser = await models.user.findUnique({ where: { id: me.id } }) const currentUser = await models.user.findUnique({ where: { id: me.id } })
const showNsfw = currentUser ? currentUser.nsfwMode : false const showNsfw = currentUser ? currentUser.nsfwMode : false
return await models.$queryRaw` return await models.$queryRawUnsafe(`
SELECT "Sub".*, "Sub".created_at as "createdAt", ss."userId" IS NOT NULL as "meSubscription", COALESCE(json_agg("MuteSub".*) FILTER (WHERE "MuteSub"."userId" IS NOT NULL), '[]') AS "MuteSub" SELECT "Sub".*, "Sub".created_at as "createdAt", COALESCE(json_agg("MuteSub".*) FILTER (WHERE "MuteSub"."userId" IS NOT NULL), '[]') AS "MuteSub"
FROM "Sub" FROM "Sub"
LEFT JOIN "SubSubscription" ss ON "Sub".name = ss."subName" AND ss."userId" = ${me.id}::INTEGER
LEFT JOIN "MuteSub" ON "Sub".name = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}::INTEGER LEFT JOIN "MuteSub" ON "Sub".name = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}::INTEGER
WHERE status <> 'STOPPED' ${showNsfw ? Prisma.empty : Prisma.sql`AND "Sub"."nsfw" = FALSE`} WHERE status <> 'STOPPED' ${showNsfw ? '' : 'AND "Sub"."nsfw" = FALSE'}
GROUP BY "Sub".name, ss."userId", "MuteSub"."userId" GROUP BY "Sub".name, "MuteSub"."userId"
ORDER BY "Sub".name ASC ORDER BY "Sub".name ASC
` `)
} }
return await models.sub.findMany({ return await models.sub.findMany({
@ -89,7 +96,7 @@ export default {
return latest?.createdAt return latest?.createdAt
}, },
topSubs: async (parent, { cursor, when, by, from, to, limit }, { models, me }) => { topSubs: async (parent, { cursor, when, by, from, to, limit = LIMIT }, { models, me }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
const range = whenRange(when, from, to || decodeCursor.time) const range = whenRange(when, from, to || decodeCursor.time)
@ -121,7 +128,7 @@ export default {
subs subs
} }
}, },
userSubs: async (_parent, { name, cursor, when, by, from, to, limit }, { models, me }) => { userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models, me }) => {
if (!name) { if (!name) {
throw new GqlInputError('must supply user name') throw new GqlInputError('must supply user name')
} }

View File

@ -55,7 +55,7 @@ async function authMethods (user, args, { models, me }) {
} }
} }
export async function topUsers (parent, { cursor, when, by, from, to, limit }, { models, me }) { export async function topUsers (parent, { cursor, when, by, from, to, limit = LIMIT }, { models, me }) {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
const range = whenRange(when, from, to || decodeCursor.time) const range = whenRange(when, from, to || decodeCursor.time)
@ -213,7 +213,7 @@ export default {
users users
} }
}, },
userSuggestions: async (parent, { q, limit }, { models }) => { userSuggestions: async (parent, { q, limit = 5 }, { models }) => {
let users = [] let users = []
if (q) { if (q) {
users = await models.$queryRaw` users = await models.$queryRaw`
@ -287,7 +287,6 @@ export default {
'r.created_at > $2', 'r.created_at > $2',
'r.created_at >= "ThreadSubscription".created_at', 'r.created_at >= "ThreadSubscription".created_at',
'r."userId" <> $1', 'r."userId" <> $1',
'"Item"."deletedAt" IS NULL',
activeOrMine(me), activeOrMine(me),
await filterClause(me, models), await filterClause(me, models),
muteClause(me), muteClause(me),
@ -605,7 +604,7 @@ export default {
SELECT * SELECT *
FROM users FROM users
WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${USER_ID.anon}, ${USER_ID.delete})) WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${USER_ID.anon}, ${USER_ID.delete}))
AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit)}` AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}`
}, },
userStatsActions: async (parent, { when, from, to }, { me, models }) => { userStatsActions: async (parent, { when, from, to }, { me, models }) => {
const range = whenRange(when, from, to) const range = whenRange(when, from, to)

View File

@ -1,18 +1,17 @@
import { gql } from 'graphql-tag' import { gql } from 'graphql-tag'
import { LIMIT } from '@/lib/cursor'
export default gql` export default gql`
extend type Query { extend type Query {
items(sub: String, sort: String, type: String, cursor: String, name: String, when: String, from: String, to: String, by: String, limit: Limit! = ${LIMIT}): Items items(sub: String, sort: String, type: String, cursor: String, name: String, when: String, from: String, to: String, by: String, limit: Limit): Items
item(id: ID!): Item item(id: ID!): Item
pageTitleAndUnshorted(url: String!): TitleUnshorted pageTitleAndUnshorted(url: String!): TitleUnshorted
dupes(url: String!): [Item!] dupes(url: String!): [Item!]
related(cursor: String, title: String, id: ID, minMatch: String, limit: Limit! = ${LIMIT}): Items related(cursor: String, title: String, id: ID, minMatch: String, limit: Limit): Items
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String, from: String, to: String): Items search(q: String, sub: String, cursor: String, what: String, sort: String, when: String, from: String, to: String): Items
auctionPosition(sub: String, id: ID, boost: Int): Int! auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions! boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int! itemRepetition(parentId: ID): Int!
newComments(itemId: ID, after: Date): Comments! newComments(rootId: ID, after: Date): Comments!
} }
type BoostPositions { type BoostPositions {
@ -65,7 +64,6 @@ export default gql`
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction! act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
pollVote(id: ID!): PollVotePaidAction! pollVote(id: ID!): PollVotePaidAction!
toggleOutlaw(id: ID!): Item! toggleOutlaw(id: ID!): Item!
updateCommentsViewAt(id: ID!, meCommentsViewedAt: Date!): Date
} }
type PollVoteResult { type PollVoteResult {
@ -151,6 +149,7 @@ export default gql`
ncomments: Int! ncomments: Int!
nDirectComments: Int! nDirectComments: Int!
comments(sort: String, cursor: String): Comments! comments(sort: String, cursor: String): Comments!
injected: Boolean!
path: String path: String
position: Int position: Int
prior: Int prior: Int
@ -173,7 +172,6 @@ export default gql`
apiKey: Boolean apiKey: Boolean
invoice: Invoice invoice: Invoice
cost: Int! cost: Int!
meCommentsViewedAt: Date
} }
input ItemForwardInput { input ItemForwardInput {

View File

@ -1,15 +1,14 @@
import { gql } from 'graphql-tag' import { gql } from 'graphql-tag'
import { LIMIT } from '@/lib/cursor'
export default gql` export default gql`
extend type Query { extend type Query {
sub(name: String): Sub sub(name: String): Sub
subLatestPost(name: String!): String subLatestPost(name: String!): String
subs: [Sub!]! subs: [Sub!]!
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit! = ${LIMIT}): Subs topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit! = ${LIMIT}): Subs userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
mySubscribedSubs(cursor: String): Subs mySubscribedSubs(cursor: String): Subs
subSuggestions(q: String!, limit: Limit! = 5): [Sub!]! subSuggestions(q: String!, limit: Limit): [Sub!]!
} }
type Subs { type Subs {

View File

@ -1,5 +1,4 @@
import { gql } from 'graphql-tag' import { gql } from 'graphql-tag'
import { LIMIT } from '@/lib/cursor'
export default gql` export default gql`
extend type Query { extend type Query {
@ -8,10 +7,10 @@ export default gql`
user(id: ID, name: String): User user(id: ID, name: String): User
users: [User!] users: [User!]
nameAvailable(name: String!): Boolean! nameAvailable(name: String!): Boolean!
topUsers(cursor: String, when: String, from: String, to: String, by: String, limit: Limit! = ${LIMIT}): UsersNullable! topUsers(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): UsersNullable!
topCowboys(cursor: String): UsersNullable! topCowboys(cursor: String): UsersNullable!
searchUsers(q: String!, limit: Limit! = 5, similarity: Float): [User!]! searchUsers(q: String!, limit: Limit, similarity: Float): [User!]!
userSuggestions(q: String, limit: Limit! = 5): [User!]! userSuggestions(q: String, limit: Limit): [User!]!
hasNewNotes: Boolean! hasNewNotes: Boolean!
mySubscribedUsers(cursor: String): Users! mySubscribedUsers(cursor: String): Users!
myMutedUsers(cursor: String): Users! myMutedUsers(cursor: String): Users!

View File

@ -55,12 +55,6 @@ const typeDefs = gql`
apiKey: VaultEntryInput! apiKey: VaultEntryInput!
): WalletSendBlink! ): WalletSendBlink!
upsertWalletSendCLNRest(
${shared},
socket: String!,
rune: VaultEntryInput!,
): WalletSendCLNRest!
upsertWalletRecvBlink( upsertWalletRecvBlink(
${shared}, ${shared},
currency: String!, currency: String!,
@ -108,11 +102,6 @@ const typeDefs = gql`
${shared} ${shared}
): WalletSendWebLN! ): WalletSendWebLN!
upsertWalletRecvClink(
${shared},
noffer: String!
): WalletRecvClink!
# tests # tests
testWalletRecvNWC( testWalletRecvNWC(
url: String! url: String!
@ -149,12 +138,9 @@ const typeDefs = gql`
apiKey: String! apiKey: String!
): Boolean! ): Boolean!
testWalletRecvClink(
noffer: String!
): Boolean!
# delete # delete
deleteWallet(id: ID!): Boolean removeWallet(id: ID!): Boolean
removeWalletProtocol(id: ID!): Boolean
# crypto # crypto
updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean
@ -163,7 +149,7 @@ const typeDefs = gql`
disablePassphraseExport: Boolean disablePassphraseExport: Boolean
# settings # settings
setWalletSettings(settings: WalletSettingsInput!): WalletSettings! setWalletSettings(settings: WalletSettingsInput!): Boolean
setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean
# logs # logs
@ -227,7 +213,6 @@ const typeDefs = gql`
| WalletSendBlink | WalletSendBlink
| WalletSendWebLN | WalletSendWebLN
| WalletSendLNC | WalletSendLNC
| WalletSendCLNRest
| WalletRecvNWC | WalletRecvNWC
| WalletRecvLNbits | WalletRecvLNbits
| WalletRecvPhoenixd | WalletRecvPhoenixd
@ -235,7 +220,6 @@ const typeDefs = gql`
| WalletRecvLightningAddress | WalletRecvLightningAddress
| WalletRecvCLNRest | WalletRecvCLNRest
| WalletRecvLNDGRPC | WalletRecvLNDGRPC
| WalletRecvClink
type WalletSettings { type WalletSettings {
receiveCreditsBelowSats: Int! receiveCreditsBelowSats: Int!
@ -290,12 +274,6 @@ const typeDefs = gql`
serverHost: VaultEntry! serverHost: VaultEntry!
} }
type WalletSendCLNRest {
id: ID!
socket: String!
rune: VaultEntry!
}
type WalletRecvNWC { type WalletRecvNWC {
id: ID! id: ID!
url: String! url: String!
@ -338,11 +316,6 @@ const typeDefs = gql`
cert: String cert: String
} }
type WalletRecvClink {
id: ID!
noffer: String!
}
input AutowithdrawSettings { input AutowithdrawSettings {
autoWithdrawThreshold: Int! autoWithdrawThreshold: Int!
autoWithdrawMaxFeePercent: Float! autoWithdrawMaxFeePercent: Float!

View File

@ -256,18 +256,5 @@ brymut,pr,#2326,,good-first-issue,,,,20k,brymut@stacker.news,2025-07-31
brymut,pr,#2332,#2276,easy,,,,100k,brymut@stacker.news,2025-07-31 brymut,pr,#2332,#2276,easy,,,,100k,brymut@stacker.news,2025-07-31
ed-kung,pr,#2373,#2371,good-first-issue,,,,20k,simplestacker@getalby.com,2025-07-31 ed-kung,pr,#2373,#2371,good-first-issue,,,,20k,simplestacker@getalby.com,2025-07-31
ed-kung,issue,#2373,#2371,good-first-issue,,,,2k,simplestacker@getalby.com,2025-07-31 ed-kung,issue,#2373,#2371,good-first-issue,,,,2k,simplestacker@getalby.com,2025-07-31
pory-gone,pr,#2381,#2370,good-first-issue,,,,20k,pory@porygone.xyz,2025-08-26 pory-gone,pr,#2381,#2370,good-first-issue,,,,20k,pory@porygone.xyz,???
pory-gone,pr,#2413,#2361,easy,,,,100k,pory@porygone.xyz,2025-08-26 pory-gone,pr,#2413,#2361,easy,,,,100k,pory@porygone.xyz,???
abhiShandy,pr,#2405,#2399,good-first-issue,,,,20k,abhishandy@stacker.news,2025-08-15
ed-kung,pr,#2239,#2197,medium,,,,250k,simplestacker@getalby.com,2025-08-15
pory-gone,pr,#2420,#2278,easy,,,,100k,pory@porygone.xyz,2025-08-26
brymut,pr,#2248,#2209,easy,,,,100k,brymut@stacker.news,2025-08-26
ed-kung,pr,#2213,#2208,medium,,,,250k,simplestacker@getalby.com,2025-08-26
ed-kung,issue,#2213,#2208,medium,,,,25k,simplestacker@getalby.com,2025-08-26
Scroogey-SN,pr,#2434,,good-first-issue,,,,20k,Scroogey@coinos.io,2025-08-26
Scroogey-SN,pr,#2446,#2444,good-first-issue,,1,,18k,Scroogey@coinos.io,2025-09-03
Scroogey-SN,pr,#2447,#2443,good-first-issue,,,,20k,Scroogey@coinos.io,2025-09-03
Scroogey-SN,pr,#2451,#2392,good-first-issue,,,,20k,Scroogey@coinos.io,2025-09-03
ed-kung,issue,#2432,#2427,medium,,,,25k,simplestacker@getalby.com,???
pory-gone,pr,#2514,#2511,good-first-issue,,1,,18k,pory@porygone.xyz,???
brymut,pr,#2566,#2555,good-first-issue,,,,20k,brymut@stacker.news,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
256 brymut pr #2332 #2276 easy 100k brymut@stacker.news 2025-07-31
257 ed-kung pr #2373 #2371 good-first-issue 20k simplestacker@getalby.com 2025-07-31
258 ed-kung issue #2373 #2371 good-first-issue 2k simplestacker@getalby.com 2025-07-31
259 pory-gone pr #2381 #2370 good-first-issue 20k pory@porygone.xyz 2025-08-26 ???
260 pory-gone pr #2413 #2361 easy 100k pory@porygone.xyz 2025-08-26 ???
abhiShandy pr #2405 #2399 good-first-issue 20k abhishandy@stacker.news 2025-08-15
ed-kung pr #2239 #2197 medium 250k simplestacker@getalby.com 2025-08-15
pory-gone pr #2420 #2278 easy 100k pory@porygone.xyz 2025-08-26
brymut pr #2248 #2209 easy 100k brymut@stacker.news 2025-08-26
ed-kung pr #2213 #2208 medium 250k simplestacker@getalby.com 2025-08-26
ed-kung issue #2213 #2208 medium 25k simplestacker@getalby.com 2025-08-26
Scroogey-SN pr #2434 good-first-issue 20k Scroogey@coinos.io 2025-08-26
Scroogey-SN pr #2446 #2444 good-first-issue 1 18k Scroogey@coinos.io 2025-09-03
Scroogey-SN pr #2447 #2443 good-first-issue 20k Scroogey@coinos.io 2025-09-03
Scroogey-SN pr #2451 #2392 good-first-issue 20k Scroogey@coinos.io 2025-09-03
ed-kung issue #2432 #2427 medium 25k simplestacker@getalby.com ???
pory-gone pr #2514 #2511 good-first-issue 1 18k pory@porygone.xyz ???
brymut pr #2566 #2555 good-first-issue 20k brymut@stacker.news ???

View File

@ -32,7 +32,7 @@ const FormStatus = {
export function BoostHelp () { export function BoostHelp () {
return ( return (
<ol> <ol style={{ lineHeight: 1.25 }}>
<li>Boost ranks items higher based on the amount</li> <li>Boost ranks items higher based on the amount</li>
<li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li> <li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li>
<li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li> <li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li>
@ -45,7 +45,6 @@ export function BoostHelp () {
</li> </li>
<li>boost can take a few minutes to show higher ranking in feed</li> <li>boost can take a few minutes to show higher ranking in feed</li>
<li>100% of boost goes to the territory founder and top stackers as rewards</li> <li>100% of boost goes to the territory founder and top stackers as rewards</li>
<li>If a boost is outlawed, it will only be visible to stackers in wild west mode</li>
</ol> </ol>
) )
} }

View File

@ -5,12 +5,11 @@ import CowboyHatIcon from '@/svgs/cowboy.svg'
import AnonIcon from '@/svgs/spy-fill.svg' import AnonIcon from '@/svgs/spy-fill.svg'
import GunIcon from '@/svgs/revolver.svg' import GunIcon from '@/svgs/revolver.svg'
import HorseIcon from '@/svgs/horse.svg' import HorseIcon from '@/svgs/horse.svg'
import BotIcon from '@/svgs/robot-2-fill.svg'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import { USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
import classNames from 'classnames' import classNames from 'classnames'
export default function Badges ({ user, badge, bot, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) { export default function Badges ({ user, badge, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) {
if (!user || Number(user.id) === USER_ID.ad) return null if (!user || Number(user.id) === USER_ID.ad) return null
if (Number(user.id) === USER_ID.anon) { if (Number(user.id) === USER_ID.anon) {
return ( return (
@ -20,7 +19,7 @@ export default function Badges ({ user, badge, bot, className = 'ms-1', badgeCla
) )
} }
let badges = [] const badges = []
const streak = user.optional.streak const streak = user.optional.streak
if (streak !== null) { if (streak !== null) {
@ -47,13 +46,6 @@ export default function Badges ({ user, badge, bot, className = 'ms-1', badgeCla
}) })
} }
if (bot) {
badges = [{
icon: BotIcon,
overlayText: 'posted as bot'
}]
}
if (badges.length === 0) return null if (badges.length === 0) return null
return ( return (

View File

@ -1,6 +1,6 @@
import { createContext, useContext, useMemo } from 'react' import { createContext, useContext, useMemo } from 'react'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import { NORMAL_POLL_INTERVAL_MS, SSR } from '@/lib/constants' import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { BLOCK_HEIGHT } from '@/fragments/blockHeight' import { BLOCK_HEIGHT } from '@/fragments/blockHeight'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
@ -18,7 +18,7 @@ export const BlockHeightProvider = ({ blockHeight, children }) => {
...(SSR ...(SSR
? {} ? {}
: { : {
pollInterval: NORMAL_POLL_INTERVAL_MS, pollInterval: NORMAL_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network' nextFetchPolicy: 'cache-and-network'
}) })
}) })

View File

@ -56,9 +56,8 @@ function useArrowKeys ({ moveLeft, moveRight }) {
function Carousel ({ close, mediaArr, src, setOptions }) { function Carousel ({ close, mediaArr, src, setOptions }) {
const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src)) const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src))
const [currentSrc, canGoLeft, canGoRight] = useMemo(() => { const [currentSrc, canGoLeft, canGoRight] = useMemo(() => {
if (index === -1) return [src, false, false]
return [mediaArr[index][0], index > 0, index < mediaArr.length - 1] return [mediaArr[index][0], index > 0, index < mediaArr.length - 1]
}, [src, mediaArr, index]) }, [mediaArr, index])
useEffect(() => { useEffect(() => {
if (index === -1) return if (index === -1) return
@ -116,12 +115,8 @@ export function CarouselProvider ({ children }) {
const showModal = useShowModal() const showModal = useShowModal()
const showCarousel = useCallback(({ src }) => { const showCarousel = useCallback(({ src }) => {
// only show confirmed entries
const confirmedEntries = Array.from(media.current.entries())
.filter(([, entry]) => entry.confirmed)
showModal((close, setOptions) => { showModal((close, setOptions) => {
return <Carousel close={close} mediaArr={confirmedEntries} src={src} setOptions={setOptions} /> return <Carousel close={close} mediaArr={Array.from(media.current.entries())} src={src} setOptions={setOptions} />
}, { }, {
fullScreen: true, fullScreen: true,
overflow: <CarouselOverflow {...media.current.get(src)} /> overflow: <CarouselOverflow {...media.current.get(src)} />
@ -129,25 +124,14 @@ export function CarouselProvider ({ children }) {
}, [showModal]) }, [showModal])
const addMedia = useCallback(({ src, originalSrc, rel }) => { const addMedia = useCallback(({ src, originalSrc, rel }) => {
media.current.set(src, { src, originalSrc, rel, confirmed: false }) media.current.set(src, { src, originalSrc, rel })
}, [])
const confirmMedia = useCallback((src) => {
const mediaItem = media.current.get(src)
if (mediaItem) {
mediaItem.confirmed = true
media.current.set(src, mediaItem)
}
}, []) }, [])
const removeMedia = useCallback((src) => { const removeMedia = useCallback((src) => {
media.current.delete(src) media.current.delete(src)
}, []) }, [])
const value = useMemo( const value = useMemo(() => ({ showCarousel, addMedia, removeMedia }), [showCarousel, addMedia, removeMedia])
() => ({ showCarousel, addMedia, confirmMedia, removeMedia }),
[showCarousel, addMedia, confirmMedia, removeMedia]
)
return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider> return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider>
} }

View File

@ -1,6 +1,6 @@
import { createContext, useContext, useMemo } from 'react' import { createContext, useContext, useMemo } from 'react'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import { NORMAL_POLL_INTERVAL_MS, SSR } from '@/lib/constants' import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { CHAIN_FEE } from '@/fragments/chainFee' import { CHAIN_FEE } from '@/fragments/chainFee'
export const ChainFeeContext = createContext({ export const ChainFeeContext = createContext({
@ -14,7 +14,7 @@ export const ChainFeeProvider = ({ chainFee, children }) => {
...(SSR ...(SSR
? {} ? {}
: { : {
pollInterval: NORMAL_POLL_INTERVAL_MS, pollInterval: NORMAL_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network' nextFetchPolicy: 'cache-and-network'
}) })
}) })

View File

@ -96,9 +96,8 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
} }
export default function Comment ({ export default function Comment ({
item, children, replyOpen, includeParent, topLevel, item, children, replyOpen, includeParent, topLevel, rootLastCommentAt,
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry, rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
navigator
}) { }) {
const [edit, setEdit] = useState() const [edit, setEdit] = useState()
const { me } = useMe() const { me } = useMe()
@ -117,18 +116,13 @@ export default function Comment ({
const unsetOutline = () => { const unsetOutline = () => {
if (!ref.current) return if (!ref.current) return
const hasOutline = ref.current.classList.contains('outline-new-comment') || ref.current.classList.contains('outline-new-injected-comment')
const hasOutlineUnset = ref.current.classList.contains('outline-new-comment-unset')
const classes = ref.current.classList // don't try to unset the outline if the comment is not outlined or we already unset the outline
const hasOutline = classes.contains('outline-new-comment') if (hasOutline && !hasOutlineUnset) {
const hasLiveOutline = classes.contains('outline-new-live-comment') ref.current.classList.add('outline-new-comment-unset')
const hasOutlineUnset = classes.contains('outline-new-comment-unset') }
// don't try to untrack and unset the outline if the comment is not outlined or we already unset the outline
if (!(hasLiveOutline || hasOutline) || hasOutlineUnset) return
classes.add('outline-new-comment-unset')
// untrack new comment and its descendants if it's not a live comment
navigator?.untrackNewComment(ref, { includeDescendants: hasOutline })
} }
useEffect(() => { useEffect(() => {
@ -157,37 +151,29 @@ export default function Comment ({
}, [item.id, cache, router.query.commentId]) }, [item.id, cache, router.query.commentId])
useEffect(() => { useEffect(() => {
// checking navigator because outlining should happen only on item pages if (me?.id === item.user?.id) return
if (!navigator || me?.id === item.user?.id) return
const itemCreatedAt = new Date(item.createdAt).getTime() const itemCreatedAt = new Date(item.createdAt).getTime()
// it's a new comment if it was created after the last comment was viewed
// or, in the case of live comments, after the last comment was created
const isNewComment = (router.query.commentsViewedAt && itemCreatedAt > router.query.commentsViewedAt) ||
(rootLastCommentAt && itemCreatedAt > new Date(rootLastCommentAt).getTime())
if (!isNewComment) return
const meViewedAt = new Date(root.meCommentsViewedAt).getTime() if (item.injected) {
const viewedAt = me?.id ? meViewedAt : router.query.commentsViewedAt // newly injected comments (item.injected) have to use a different class to outline every new comment
ref.current.classList.add('outline-new-injected-comment')
const isNewComment = viewedAt && itemCreatedAt > viewedAt
// live comments are new regardless of me or anon view time
const rootLast = new Date(root.lastCommentAt || root.createdAt).getTime()
const isNewLiveComment = item.live && itemCreatedAt > (meViewedAt || rootLast)
if (!isNewComment && !isNewLiveComment) return
if (item.live) {
// live comments (item.live) have to use a different class to outline every new comment
ref.current.classList.add('outline-new-live-comment')
// wait for the injection animation to end before removing its class // wait for the injection animation to end before removing its class
ref.current.addEventListener('animationend', () => { ref.current.addEventListener('animationend', () => {
ref.current.classList.remove(styles.liveComment) ref.current.classList.remove(styles.injectedComment)
}, { once: true }) }, { once: true })
// animate the live comment injection // animate the live comment injection
ref.current.classList.add(styles.liveComment) ref.current.classList.add(styles.injectedComment)
} else { } else {
ref.current.classList.add('outline-new-comment') ref.current.classList.add('outline-new-comment')
} }
}, [item.id, rootLastCommentAt])
navigator.trackNewComment(ref, itemCreatedAt)
}, [item.id, root.lastCommentAt, root.meCommentsViewedAt])
const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0) const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0)
// Don't show OP badge when anon user comments on anon user posts // Don't show OP badge when anon user comments on anon user posts
@ -309,7 +295,7 @@ export default function Comment ({
? ( ? (
<> <>
{item.comments.comments.map((item) => ( {item.comments.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} navigator={navigator} /> <Comment depth={depth + 1} key={item.id} item={item} rootLastCommentAt={rootLastCommentAt} />
))} ))}
{item.comments.comments.length < item.nDirectComments && ( {item.comments.comments.length < item.nDirectComments && (
<div className={`d-block ${styles.comment} pb-2 ps-3`}> <div className={`d-block ${styles.comment} pb-2 ps-3`}>

View File

@ -18,7 +18,9 @@
.dontLike { .dontLike {
fill: #a5a5a5; fill: #a5a5a5;
margin-right: 2px; margin-right: .35rem;
padding: 2px;
margin-left: 1px;
margin-top: 9px; margin-top: 9px;
cursor: pointer; cursor: pointer;
} }
@ -62,23 +64,19 @@
.children { .children {
margin-top: 0; margin-top: 0;
margin-left: 15px; margin-left: 27px;
} }
.comments { .comments {
margin-left: -15px; margin-left: -.75rem;
} }
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.comments { .comments {
margin-left: 0px; margin-left: .75rem;
} }
}
.children {
margin-left: 25px;
}
}
.skeleton .hunk { .skeleton .hunk {
width: 100%; width: 100%;
@ -139,30 +137,34 @@
padding-top: .5rem; padding-top: .5rem;
} }
.liveComment { .newCommentDot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--bs-primary);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
background-color: #80d3ff;
opacity: 0.7;
}
50% {
background-color: #007cbe;
opacity: 1;
}
100% {
background-color: #80d3ff;
opacity: 0.7;
}
}
.injectedComment {
animation: fadeIn 0.5s ease-out; animation: fadeIn 0.5s ease-out;
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
}
.commentNavigator {
display: flex;
align-items: center;
vertical-align: middle;
gap: 0.2rem;
padding-bottom: 0.2rem;
justify-content: center;
cursor: pointer;
/* prevent double tap from zooming */
touch-action: manipulation;
}
.newCommentDot {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #007cbe;
} }

View File

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

View File

@ -7,7 +7,6 @@ import Flag from '@/svgs/flag-fill.svg'
import { useMemo } from 'react' import { useMemo } from 'react'
import getColor from '@/lib/rainbow' import getColor from '@/lib/rainbow'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import styles from './upvote.module.css'
export function DownZap ({ item, ...props }) { export function DownZap ({ item, ...props }) {
const { meDontLikeSats } = item const { meDontLikeSats } = item
@ -21,9 +20,7 @@ export function DownZap ({ item, ...props }) {
<DownZapper <DownZapper
item={item} As={({ ...oprops }) => item={item} As={({ ...oprops }) =>
<div className='upvoteParent'> <div className='upvoteParent'>
<div className={styles.upvoteWrapper}> <Flag {...props} {...oprops} style={style} />
<Flag {...props} {...oprops} style={style} />
</div>
</div>} </div>}
/> />
) )

View File

@ -1,48 +0,0 @@
import { createContext, useContext, useMemo, useState } from 'react'
import { useHasNewNotes } from './use-has-new-notes'
import Head from 'next/head'
const FAVICONS = {
default: '/favicon.png',
notify: '/favicon-notify.png',
comments: '/favicon-comments.png',
notifyWithComments: '/favicon-notify-with-comments.png'
}
const getFavicon = (hasNewNotes, hasNewComments) => {
if (hasNewNotes && hasNewComments) return FAVICONS.notifyWithComments
if (hasNewNotes) return FAVICONS.notify
if (hasNewComments) return FAVICONS.comments
return FAVICONS.default
}
export const FaviconContext = createContext()
export default function FaviconProvider ({ children }) {
const hasNewNotes = useHasNewNotes()
const [hasNewComments, setHasNewComments] = useState(false)
const favicon = useMemo(() =>
getFavicon(hasNewNotes, hasNewComments),
[hasNewNotes, hasNewComments])
const contextValue = useMemo(() => ({
favicon,
hasNewNotes,
hasNewComments,
setHasNewComments
}), [favicon, hasNewNotes, hasNewComments, setHasNewComments])
return (
<FaviconContext.Provider value={contextValue}>
<Head>
<link rel='shortcut icon' href={favicon} />
</Head>
{children}
</FaviconContext.Provider>
)
}
export function useFavicon () {
return useContext(FaviconContext)
}

View File

@ -4,7 +4,7 @@ import ActionTooltip from './action-tooltip'
import Info from './info' import Info from './info'
import styles from './fee-button.module.css' import styles from './fee-button.module.css'
import { gql, useQuery } from '@apollo/client' import { gql, useQuery } from '@apollo/client'
import { ANON_FEE_MULTIPLIER, FAST_POLL_INTERVAL_MS, SSR } from '@/lib/constants' import { ANON_FEE_MULTIPLIER, FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import { useMe } from './me' import { useMe } from './me'
import AnonIcon from '@/svgs/spy-fill.svg' import AnonIcon from '@/svgs/spy-fill.svg'
@ -14,7 +14,7 @@ import { SubmitButton } from './form'
const FeeButtonContext = createContext() const FeeButtonContext = createContext()
export function postCommentBaseLineItems ({ baseCost = 1, comment = false, bio = false, me }) { export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me }) {
const anonCharge = me const anonCharge = me
? {} ? {}
: { : {
@ -28,10 +28,10 @@ export function postCommentBaseLineItems ({ baseCost = 1, comment = false, bio =
return { return {
baseCost: { baseCost: {
term: baseCost, term: baseCost,
label: `${bio ? 'bio' : (comment ? 'comment' : 'post')} cost`, label: `${comment ? 'comment' : 'post'} cost`,
op: '_', op: '_',
modifier: (cost) => cost + baseCost, modifier: (cost) => cost + baseCost,
allowFreebies: bio allowFreebies: comment
}, },
...anonCharge ...anonCharge
} }
@ -45,7 +45,7 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
return function useRemoteLineItems () { return function useRemoteLineItems () {
const [line, setLine] = useState({}) const [line, setLine] = useState({})
const { data } = useQuery(query, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL_MS, nextFetchPolicy: 'cache-and-network' }) const { data } = useQuery(query, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL, nextFetchPolicy: 'cache-and-network' })
useEffect(() => { useEffect(() => {
const repetition = data?.itemRepetition const repetition = data?.itemRepetition

View File

@ -1,7 +1,7 @@
import { gql, useQuery } from '@apollo/client' import { gql, useQuery } from '@apollo/client'
import Link from 'next/link' import Link from 'next/link'
import { RewardLine } from '@/pages/rewards' import { RewardLine } from '@/pages/rewards'
import { LONG_POLL_INTERVAL_MS, SSR } from '@/lib/constants' import { LONG_POLL_INTERVAL, SSR } from '@/lib/constants'
const REWARDS = gql` const REWARDS = gql`
{ {
@ -12,7 +12,7 @@ const REWARDS = gql`
}` }`
export default function Rewards () { export default function Rewards () {
const { data } = useQuery(REWARDS, SSR ? { ssr: false } : { pollInterval: LONG_POLL_INTERVAL_MS, nextFetchPolicy: 'cache-and-network' }) const { data } = useQuery(REWARDS, SSR ? { ssr: false } : { pollInterval: LONG_POLL_INTERVAL, nextFetchPolicy: 'cache-and-network' })
const total = data?.rewards?.[0]?.total const total = data?.rewards?.[0]?.total
const time = data?.rewards?.[0]?.time const time = data?.rewards?.[0]?.time
return ( return (

View File

@ -12,13 +12,10 @@ import No from '@/svgs/no.svg'
import Bolt from '@/svgs/bolt.svg' import Bolt from '@/svgs/bolt.svg'
import Amboss from '@/svgs/amboss.svg' import Amboss from '@/svgs/amboss.svg'
import Mempool from '@/svgs/bimi.svg' import Mempool from '@/svgs/bimi.svg'
import Live from '@/svgs/chat-unread-fill.svg'
import NoLive from '@/svgs/chat-off-fill.svg'
import Rewards from './footer-rewards' import Rewards from './footer-rewards'
import useDarkMode from './dark-mode' import useDarkMode from './dark-mode'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
import { useAnimationEnabled } from '@/components/animation' import { useAnimationEnabled } from '@/components/animation'
import { useLiveCommentsToggle } from './use-live-comments'
const RssPopover = ( const RssPopover = (
<Popover> <Popover>
@ -100,6 +97,13 @@ const SocialsPopover = (
const ChatPopover = ( const ChatPopover = (
<Popover> <Popover>
<Popover.Body style={{ fontWeight: 500, fontSize: '.9rem' }}> <Popover.Body style={{ fontWeight: 500, fontSize: '.9rem' }}>
{/* <a
href='https://tribes.sphinx.chat/t/stackerzchat' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
sphinx
</a>
<span className='mx-2 text-muted'> \ </span> */}
<a <a
href='https://t.me/k00bideh' className='nav-link p-0 d-inline-flex' href='https://t.me/k00bideh' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer' target='_blank' rel='noreferrer'
@ -108,10 +112,10 @@ const ChatPopover = (
</a> </a>
<span className='mx-2 text-muted'> \ </span> <span className='mx-2 text-muted'> \ </span>
<a <a
href='https://signal.group/#CjQKIEt57YiluJoTW3lZqaqAq6echCekEYFfg7eIua2X91nLEhA__6ALI9pkaY_McQqX0jm1' className='nav-link p-0 d-inline-flex' href='https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE%3D%40smp10.simplex.im%2FebLYaEFGjsD3uK4fpE326c5QI1RZSxau%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAV086Oj5yCsavWzIbRMCVuF6jq793Tt__rWvCec__viI%253D%26srv%3Drb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22cZwSGoQhyOUulzp7rwCdWQ%3D%3D%22%7D' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer' target='_blank' rel='noreferrer'
> >
signal simplex
</a> </a>
</Popover.Body> </Popover.Body>
</Popover> </Popover>
@ -143,11 +147,8 @@ export default function Footer ({ links = true }) {
const [animationEnabled, toggleAnimation] = useAnimationEnabled() const [animationEnabled, toggleAnimation] = useAnimationEnabled()
const [disableLiveComments, toggleLiveComments] = useLiveCommentsToggle()
const DarkModeIcon = darkMode ? Sun : Moon const DarkModeIcon = darkMode ? Sun : Moon
const LnIcon = animationEnabled ? No : Bolt const LnIcon = animationEnabled ? No : Bolt
const LiveIcon = disableLiveComments ? Live : NoLive
const version = process.env.NEXT_PUBLIC_COMMIT_HASH const version = process.env.NEXT_PUBLIC_COMMIT_HASH
@ -163,9 +164,6 @@ export default function Footer ({ links = true }) {
<ActionTooltip notForm overlayText={`${animationEnabled ? 'disable' : 'enable'} lightning animations`}> <ActionTooltip notForm overlayText={`${animationEnabled ? 'disable' : 'enable'} lightning animations`}>
<LnIcon onClick={toggleAnimation} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning /> <LnIcon onClick={toggleAnimation} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
</ActionTooltip> </ActionTooltip>
<ActionTooltip notForm overlayText={`${disableLiveComments ? 'enable' : 'disable'} live comments`}>
<LiveIcon onClick={toggleLiveComments} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
</ActionTooltip>
</div> </div>
<div className='mb-0' style={{ fontWeight: 500 }}> <div className='mb-0' style={{ fontWeight: 500 }}>
<Rewards /> <Rewards />
@ -251,9 +249,6 @@ export default function Footer ({ links = true }) {
<Link href='/ek' className='ms-1'> <Link href='/ek' className='ms-1'>
@ek @ek
</Link> </Link>
<Link href='/sox' className='ms-1'>
@sox
</Link>
<span className='ms-1'>&</span> <span className='ms-1'>&</span>
<Link href='https://github.com/stackernews/stacker.news/graphs/contributors' className='ms-1' target='_blank' rel='noreferrer'> <Link href='https://github.com/stackernews/stacker.news/graphs/contributors' className='ms-1' target='_blank' rel='noreferrer'>
more more

View File

@ -1451,7 +1451,6 @@ export function MultiInput ({
onChange, autoFocus, hideError, inputType = 'text', onChange, autoFocus, hideError, inputType = 'text',
...props ...props
}) { }) {
const formik = useFormikContext()
const [inputs, setInputs] = useState(new Array(length).fill('')) const [inputs, setInputs] = useState(new Array(length).fill(''))
const inputRefs = useRef(new Array(length).fill(null)) const inputRefs = useRef(new Array(length).fill(null))
const [, meta, helpers] = useField({ name }) const [, meta, helpers] = useField({ name })
@ -1550,7 +1549,7 @@ export function MultiInput ({
))} ))}
</div> </div>
<div> <div>
{hideError && formik.submitCount > 0 && meta.touched && meta.error && ( // custom error message is showed if hideError is true {hideError && meta.touched && meta.error && ( // custom error message is showed if hideError is true
<BootstrapForm.Control.Feedback type='invalid' className='d-block'> <BootstrapForm.Control.Feedback type='invalid' className='d-block'>
{meta.error} {meta.error}
</BootstrapForm.Control.Feedback> </BootstrapForm.Control.Feedback>

View File

@ -5,7 +5,7 @@ export default function CCInfo (props) {
return ( return (
<Info {...props}> <Info {...props}>
<h6>Why am I getting cowboy credits?</h6> <h6>Why am I getting cowboy credits?</h6>
<ul> <ul className='line-height-md'>
<li>to receive sats, you must attach an <Link href='/wallets'>external receiving wallet</Link></li> <li>to receive sats, you must attach an <Link href='/wallets'>external receiving wallet</Link></li>
<li>zappers may have chosen to send you CCs instead of sats</li> <li>zappers may have chosen to send you CCs instead of sats</li>
<li>if the zaps are split on a post, recipients will receive CCs regardless of their configured receiving wallet</li> <li>if the zaps are split on a post, recipients will receive CCs regardless of their configured receiving wallet</li>

View File

@ -5,7 +5,7 @@ export default function RewardSatsInfo (props) {
return ( return (
<Info {...props}> <Info {...props}>
<h6>Where did my sats come from?</h6> <h6>Where did my sats come from?</h6>
<ul> <ul className='line-height-md'>
<li>you may have sats from before <Link href='/items/835465'>SN went not-custodial</Link></li> <li>you may have sats from before <Link href='/items/835465'>SN went not-custodial</Link></li>
<li>sats also come from <Link href='/rewards'>daily rewards</Link> and territory revenue <li>sats also come from <Link href='/rewards'>daily rewards</Link> and territory revenue
<ul> <ul>

View File

@ -7,7 +7,7 @@ import PayerData from './payer-data'
import Bolt11Info from './bolt11-info' import Bolt11Info from './bolt11-info'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import { INVOICE } from '@/fragments/invoice' import { INVOICE } from '@/fragments/invoice'
import { FAST_POLL_INTERVAL_MS, SSR } from '@/lib/constants' import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/client/errors' import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/client/errors'
import ItemJob from './item-job' import ItemJob from './item-job'
import Item from './item' import Item from './item'
@ -24,7 +24,7 @@ export default function Invoice ({
const { data, error } = useQuery(query, SSR const { data, error } = useQuery(query, SSR
? {} ? {}
: { : {
pollInterval: FAST_POLL_INTERVAL_MS, pollInterval: FAST_POLL_INTERVAL,
variables: { id }, variables: { id },
nextFetchPolicy: 'cache-and-network', nextFetchPolicy: 'cache-and-network',
skip: !poll skip: !poll

View File

@ -11,6 +11,7 @@ import { useMe } from './me'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import { useEffect } from 'react' import { useEffect } from 'react'
import Poll from './poll' import Poll from './poll'
import { commentsViewed } from '@/lib/new-comments'
import Related from './related' import Related from './related'
import PastBounties from './past-bounties' import PastBounties from './past-bounties'
import Check from '@/svgs/check-double-line.svg' import Check from '@/svgs/check-double-line.svg'
@ -25,7 +26,6 @@ import { UNKNOWN_LINK_REL } from '@/lib/constants'
import classNames from 'classnames' import classNames from 'classnames'
import { CarouselProvider } from './carousel' import { CarouselProvider } from './carousel'
import Embed from './embed' import Embed from './embed'
import useCommentsView from './use-comments-view'
function BioItem ({ item, handleClick }) { function BioItem ({ item, handleClick }) {
const { me } = useMe() const { me } = useMe()
@ -161,12 +161,9 @@ function ItemText ({ item }) {
} }
export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props }) { export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props }) {
// no cache update here because we need to preserve the initial value
const { markItemViewed } = useCommentsView(item.id, { updateCache: false })
useEffect(() => { useEffect(() => {
markItemViewed(item) commentsViewed(item)
}, [item.id, markItemViewed]) }, [item.lastCommentAt])
return ( return (
<> <>

View File

@ -134,7 +134,7 @@ export default function ItemInfo ({
{showUser && {showUser &&
<Link href={`/${item.user.name}`}> <Link href={`/${item.user.name}`}>
<UserPopover name={item.user.name}>@{item.user.name}</UserPopover> <UserPopover name={item.user.name}>@{item.user.name}</UserPopover>
<Badges badgeClassName='fill-grey' spacingClassName='ms-xs' height={12} width={12} user={item.user} bot={item.apiKey} /> <Badges badgeClassName='fill-grey' spacingClassName='ms-xs' height={12} width={12} user={item.user} />
{embellishUser} {embellishUser}
</Link>} </Link>}
<span> </span> <span> </span>
@ -166,6 +166,9 @@ export default function ItemInfo ({
{' '}<Badge className={styles.newComment} bg={null}>freebie</Badge> {' '}<Badge className={styles.newComment} bg={null}>freebie</Badge>
</Link> </Link>
)} )}
{(item.apiKey &&
<>{' '}<Badge className={styles.newComment} bg={null}>bot</Badge></>
)}
{extraBadges} {extraBadges}
{ {
showActionDropdown && showActionDropdown &&

View File

@ -76,9 +76,10 @@ a.title:visited {
.dontLike { .dontLike {
fill: #a5a5a5; fill: #a5a5a5;
margin-right: .35rem;
margin-left: -.2rem;
flex-shrink: 0; flex-shrink: 0;
cursor: pointer; cursor: pointer;
margin-right: 2px;
} }
.case { .case {

View File

@ -114,7 +114,7 @@ export default function JobForm ({ item, sub }) {
label={ label={
<div className='d-flex align-items-center'>boost <div className='d-flex align-items-center'>boost
<Info> <Info>
<ol> <ol className='line-height-md'>
<li>Boost ranks jobs higher based on the amount</li> <li>Boost ranks jobs higher based on the amount</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li> <li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
<li>Boost must be divisible by {numWithUnits(BOOST_MULT, { abbreviate: false })}</li> <li>Boost must be divisible by {numWithUnits(BOOST_MULT, { abbreviate: false })}</li>

View File

@ -9,7 +9,7 @@ import Qr, { QrSkeleton } from './qr'
import styles from './lightning-auth.module.css' import styles from './lightning-auth.module.css'
import BackIcon from '@/svgs/arrow-left-line.svg' import BackIcon from '@/svgs/arrow-left-line.svg'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { FAST_POLL_INTERVAL_MS, SSR } from '@/lib/constants' import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) { function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
const query = gql` const query = gql`
@ -19,7 +19,7 @@ function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
k1 k1
} }
}` }`
const { data } = useQuery(query, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL_MS, nextFetchPolicy: 'cache-and-network' }) const { data } = useQuery(query, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL, nextFetchPolicy: 'cache-and-network' })
useEffect(() => { useEffect(() => {
if (data?.lnAuth?.pubkey) { if (data?.lnAuth?.pubkey) {

View File

@ -75,8 +75,6 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error, signin)) const [errorMessage, setErrorMessage] = useState(authErrorMessage(error, signin))
const router = useRouter() const router = useRouter()
multiAuth = typeof multiAuth === 'string' ? multiAuth === 'true' : !!multiAuth
// signup/signin awareness cookie // signup/signin awareness cookie
useEffect(() => { useEffect(() => {
// expire cookie if we're on /signup instead of /login // expire cookie if we're on /signup instead of /login

View File

@ -1,14 +1,14 @@
import React, { useContext } from 'react' import React, { useContext } from 'react'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import { ME } from '@/fragments/users' import { ME } from '@/fragments/users'
import { FAST_POLL_INTERVAL_MS, SSR } from '@/lib/constants' import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
export const MeContext = React.createContext({ export const MeContext = React.createContext({
me: null me: null
}) })
export function MeProvider ({ me, children }) { export function MeProvider ({ me, children }) {
const { data, refetch } = useQuery(ME, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL_MS, nextFetchPolicy: 'cache-and-network' }) const { data, refetch } = useQuery(ME, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL, nextFetchPolicy: 'cache-and-network' })
// this makes sure that we always use the fetched data if it's null. // this makes sure that we always use the fetched data if it's null.
// without this, we would always fallback to the `me` object // without this, we would always fallback to the `me` object
// which was passed during page load which (visually) breaks switching to anon // which was passed during page load which (visually) breaks switching to anon

View File

@ -75,19 +75,12 @@ const Media = memo(function Media ({
export default function MediaOrLink ({ linkFallback = true, ...props }) { export default function MediaOrLink ({ linkFallback = true, ...props }) {
const media = useMediaHelper(props) const media = useMediaHelper(props)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const { showCarousel, addMedia, confirmMedia, removeMedia } = useCarousel() const { showCarousel, addMedia, removeMedia } = useCarousel()
// register placeholder immediately on mount if we have a src
useEffect(() => {
if (!media.bestResSrc) return
addMedia({ src: media.bestResSrc, originalSrc: media.originalSrc, rel: props.rel })
}, [addMedia, media.bestResSrc, media.originalSrc, props.rel])
// confirm media for carousel based on image detection
useEffect(() => { useEffect(() => {
if (!media.image) return if (!media.image) return
confirmMedia(media.bestResSrc) addMedia({ src: media.bestResSrc, originalSrc: media.originalSrc, rel: props.rel })
}, [confirmMedia, media.image, media.bestResSrc]) }, [media.image])
const handleClick = useCallback(() => showCarousel({ src: media.bestResSrc }), const handleClick = useCallback(() => showCarousel({ src: media.bestResSrc }),
[showCarousel, media.bestResSrc]) [showCarousel, media.bestResSrc])
@ -144,7 +137,7 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
img.decode().then(() => { // decoding beforehand to prevent wrong image cropping img.decode().then(() => { // decoding beforehand to prevent wrong image cropping
setIsImage(true) setIsImage(true)
}).catch((e) => { }).catch((e) => {
console.warn('Cannot decode image:', src, e) console.error('Cannot decode image', e)
}) })
} }
video.src = src video.src = src

View File

@ -1,224 +0,0 @@
import { createContext, Fragment, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import classNames from 'classnames'
import { useRouter } from 'next/router'
const MultiStepFormContext = createContext()
export function MultiStepForm ({ children, initial, steps }) {
const [stepIndex, setStepIndex] = useState(0)
const [formState, setFormState] = useState({})
const router = useRouter()
useEffect(() => {
// initial state might not be available on first render so we sync changes
if (initial) setFormState(initial)
}, [initial])
useEffect(() => {
const idx = Math.max(0, steps.indexOf(router.query.step))
setStepIndex(idx)
router.replace({
pathname: router.pathname,
query: { type: router.query.type, step: steps[idx] }
}, null, { shallow: true })
}, [router.query.step, steps])
const next = useCallback(() => {
const idx = Math.min(stepIndex + 1, steps.length - 1)
router.push(
{ pathname: router.pathname, query: { type: router.query.type, step: steps[idx] } },
null,
{ shallow: true }
)
}, [stepIndex, steps, router])
const prev = useCallback(() => router.back(), [router])
const updateFormState = useCallback((id, state) => {
setFormState(formState => {
return id ? { ...formState, [id]: state } : state
})
}, [])
const value = useMemo(
() => ({ stepIndex, steps, next, prev, formState, updateFormState }),
[stepIndex, steps, next, prev, formState, updateFormState])
return (
<MultiStepFormContext.Provider value={value}>
<Progress />
{children[stepIndex]}
</MultiStepFormContext.Provider>
)
}
function Progress () {
const steps = useSteps()
const maxSteps = useMaxSteps()
const stepIndex = useStepIndex()
const style = (index) => {
switch (index) {
case 0: return maxSteps === 2 ? { marginLeft: '-13px', marginRight: '-15px' } : { marginLeft: '-5px', marginRight: '-13px' }
case 1: return { marginLeft: '-13px', marginRight: '-15px' }
default: return {}
}
}
return (
<div className='d-flex my-3 mx-auto'>
{
steps.map((label, i) => {
const last = i === steps.length - 1
return (
<Fragment key={i}>
<ProgressNumber number={i + 1} label={label} active={stepIndex >= i} />
{!last && <ProgressLine style={style(i)} active={stepIndex >= i + 1} />}
</Fragment>
)
})
}
</div>
)
}
function ProgressNumber ({ number, label, active }) {
return (
<div className={classNames('z-1 text-center', { 'text-info': active })}>
<NumberSVG number={number} active={active} />
<div className={classNames('small pt-1', active ? 'text-info' : 'text-muted')}>
{label}
</div>
</div>
)
}
const NUMBER_SVG_WIDTH = 24
const NUMBER_SVG_HEIGHT = 24
function NumberSVG ({ number, active }) {
const width = NUMBER_SVG_WIDTH
const height = NUMBER_SVG_HEIGHT
const Wrapper = ({ children }) => (
<div style={{ position: 'relative', width: `${width}px`, height: `${height}px`, margin: '0 auto' }}>
{children}
</div>
)
const Circle = () => {
const circleProps = {
fill: active ? 'var(--bs-info)' : 'var(--bs-body-bg)',
stroke: active ? 'var(--bs-info)' : 'var(--theme-grey)'
}
return (
<svg
xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'
width={width} height={height}
style={{ position: 'absolute', top: 0, left: 0 }}
>
<circle cx='12' cy='12' r='11' strokeWidth='1' {...circleProps} />
</svg>
)
}
const Number = () => {
const svgProps = {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
// we scale the number down and render it in the center of the circle
width: 0.5 * width,
height: 0.5 * height,
style: { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }
}
const numberColor = active ? 'var(--bs-white)' : 'var(--theme-grey)'
// svgs are from https://remixicon.com/icon/number-1 etc.
switch (number) {
case 1:
return (
<svg {...svgProps}>
<path fill={numberColor} d='M14 1.5V22H12V3.704L7.5 4.91V2.839L12.5 1.5H14Z' />
</svg>
)
case 2:
return (
<svg {...svgProps}>
<path
fill={numberColor}
d='M16.0002 7.5C16.0002 5.29086 14.2094 3.5 12.0002 3.5C9.7911 3.5 8.00024 5.29086 8.00024 7.5H6.00024C6.00024 4.18629 8.68653 1.5 12.0002 1.5C15.314 1.5 18.0002 4.18629 18.0002 7.5C18.0002 8.93092 17.4993 10.2448 16.6633 11.276L9.344 19.9991L18.0002 20V22H6.00024L6 20.8731L15.0642 10.071C15.6485 9.37595 16.0002 8.47905 16.0002 7.5Z'
/>
</svg>
)
case 3:
return (
<svg {...svgProps}>
<path fill={numberColor} d='M18.0001 2V3.36217L12.8087 9.54981C16.0169 9.94792 18.5001 12.684 18.5001 16C18.5001 19.5899 15.5899 22.5 12.0001 22.5C8.95434 22.5 6.39789 20.4052 5.69287 17.5778L7.63351 17.0922C8.12156 19.0497 9.89144 20.5 12.0001 20.5C14.4853 20.5 16.5001 18.4853 16.5001 16C16.5001 13.5147 14.4853 11.5 12.0001 11.5C11.2795 11.5 10.5985 11.6694 9.99465 11.9705L9.76692 12.0923L9.07705 10.8852L14.8551 3.99917L6.50006 4V2H18.0001Z' />
</svg>
)
default:
return null
}
}
return (
<Wrapper>
<Circle />
<Number />
</Wrapper>
)
}
function ProgressLine ({ style, active }) {
const svgStyle = { display: 'block', position: 'relative', top: `${NUMBER_SVG_HEIGHT / 2}px` }
return (
<div style={style}>
<svg style={svgStyle} width='100%' height='1' viewBox='0 0 100 1' preserveAspectRatio='none'>
<path
d='M 0 1 L 100 1'
stroke={active ? 'var(--bs-info)' : 'var(--theme-grey)'}
strokeWidth='1'
fill='none'
/>
</svg>
</div>
)
}
function useSteps () {
const { steps } = useContext(MultiStepFormContext)
return steps
}
export function useStepIndex () {
const { stepIndex } = useContext(MultiStepFormContext)
return stepIndex
}
export function useMaxSteps () {
const steps = useSteps()
return steps.length
}
export function useStep () {
const stepIndex = useStepIndex()
const steps = useSteps()
return steps[stepIndex]
}
export function useNext () {
const { next } = useContext(MultiStepFormContext)
return next
}
export function usePrev () {
const { prev } = useContext(MultiStepFormContext)
return prev
}
export function useFormState (id) {
const { formState, updateFormState } = useContext(MultiStepFormContext)
const setFormState = useCallback(state => updateFormState(id, state), [id, updateFormState])
return useMemo(
() => [
id ? formState[id] : formState,
setFormState
], [formState, id, setFormState])
}

View File

@ -18,10 +18,12 @@ import SearchIcon from '../../svgs/search-line.svg'
import classNames from 'classnames' import classNames from 'classnames'
import SnIcon from '@/svgs/sn.svg' import SnIcon from '@/svgs/sn.svg'
import { useHasNewNotes } from '../use-has-new-notes' import { useHasNewNotes } from '../use-has-new-notes'
// import { useWallets } from '@/wallets/client/hooks'
import { useWalletIndicator } from '@/wallets/client/hooks' import { useWalletIndicator } from '@/wallets/client/hooks'
import SwitchAccountList, { nextAccount, useAccounts } from '@/components/account' import SwitchAccountList, { nextAccount, useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal' import { useShowModal } from '@/components/modal'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import Head from 'next/head'
export function Brand ({ className }) { export function Brand ({ className }) {
return ( return (
@ -119,6 +121,9 @@ export function NavNotifications ({ className }) {
return ( return (
<> <>
<Head>
<link rel='shortcut icon' href={hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
</Head>
<Link href='/notifications' passHref legacyBehavior> <Link href='/notifications' passHref legacyBehavior>
<Nav.Link eventKey='notifications' className={classNames('position-relative', className)}> <Nav.Link eventKey='notifications' className={classNames('position-relative', className)}>
<NoteIcon height={28} width={20} className='theme' /> <NoteIcon height={28} width={20} className='theme' />
@ -159,20 +164,23 @@ export function NavWalletSummary ({ className }) {
) )
} }
export const Indicator = ({ show, children }) => { export const Indicator = ({ superscript }) => {
return ( if (superscript) {
<div className='w-fit-content position-relative'> return (
{children} <span className='d-inline-block p-1'>
{show && ( <span
<span className='d-inline-block p-1'> className='position-absolute p-1 bg-secondary'
<span style={{ top: '5px', right: '0px', height: '5px', width: '5px' }}
className='position-absolute p-1 bg-secondary' >
style={{ top: '5px', right: '0px', height: '5px', width: '5px' }} <span className='invisible'>{' '}</span>
>
<span className='invisible'>{' '}</span>
</span>
</span> </span>
)} </span>
)
}
return (
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
</div> </div>
) )
} }
@ -190,7 +198,8 @@ export function MeDropdown ({ me, dropNavKey }) {
<Dropdown.Toggle className='nav-link nav-item fw-normal' id='profile' variant='custom'> <Dropdown.Toggle className='nav-link nav-item fw-normal' id='profile' variant='custom'>
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
<Nav.Link eventKey={me.name} as='span' className='p-0 position-relative'> <Nav.Link eventKey={me.name} as='span' className='p-0 position-relative'>
<Indicator show={indicator}>@{me.name}</Indicator> {`@${me.name}`}
{indicator && <Indicator superscript />}
</Nav.Link> </Nav.Link>
<Badges user={me} /> <Badges user={me} />
</div> </div>
@ -198,7 +207,8 @@ export function MeDropdown ({ me, dropNavKey }) {
<Dropdown.Menu> <Dropdown.Menu>
<Link href={'/' + me.name} passHref legacyBehavior> <Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}> <Dropdown.Item active={me.name === dropNavKey}>
<Indicator show={profileIndicator}>profile</Indicator> profile
{profileIndicator && <Indicator />}
</Dropdown.Item> </Dropdown.Item>
</Link> </Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior> <Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
@ -206,7 +216,8 @@ export function MeDropdown ({ me, dropNavKey }) {
</Link> </Link>
<Link href='/wallets' passHref legacyBehavior> <Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'> <Dropdown.Item eventKey='wallets'>
<Indicator show={walletIndicator}>wallets</Indicator> wallets
{walletIndicator && <Indicator />}
</Dropdown.Item> </Dropdown.Item>
</Link> </Link>
<Link href='/credits' passHref legacyBehavior> <Link href='/credits' passHref legacyBehavior>
@ -282,6 +293,7 @@ export default function LoginButton () {
function LogoutObstacle ({ onClose }) { function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker() const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
// const { removeLocalWallets } = useWallets()
const router = useRouter() const router = useRouter()
return ( return (

View File

@ -2,11 +2,9 @@ import { Nav, Navbar } from 'react-bootstrap'
import styles from '../../header.module.css' import styles from '../../header.module.css'
import { AnonCorner, Back, Brand, MeCorner, NavPrice, SearchItem } from '../common' import { AnonCorner, Back, Brand, MeCorner, NavPrice, SearchItem } from '../common'
import { useMe } from '../../me' import { useMe } from '../../me'
import { useCommentsNavigatorContext, CommentsNavigator } from '@/components/use-comments-navigator'
export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) { export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const { me } = useMe() const { me } = useMe()
const { navigator, commentCount } = useCommentsNavigatorContext()
return ( return (
<Navbar> <Navbar>
<Nav <Nav
@ -17,7 +15,6 @@ export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
<Brand className='me-1' /> <Brand className='me-1' />
<SearchItem prefix={prefix} className='me-0 ms-2 d-none d-md-flex' /> <SearchItem prefix={prefix} className='me-0 ms-2 d-none d-md-flex' />
<NavPrice className='ms-auto me-0 mx-md-auto d-none d-md-flex' /> <NavPrice className='ms-auto me-0 mx-md-auto d-none d-md-flex' />
<CommentsNavigator navigator={navigator} commentCount={commentCount} />
{me {me
? <MeCorner dropNavKey={dropNavKey} me={me} className='d-none d-md-flex' /> ? <MeCorner dropNavKey={dropNavKey} me={me} className='d-none d-md-flex' />
: <AnonCorner path={path} className='d-none d-md-flex' />} : <AnonCorner path={path} className='d-none d-md-flex' />}

View File

@ -28,11 +28,11 @@ export default function OffCanvas ({ me, dropNavKey }) {
const profileIndicator = me && !me.bioId const profileIndicator = me && !me.bioId
const walletIndicator = useWalletIndicator() const walletIndicator = useWalletIndicator()
const indicator = profileIndicator || walletIndicator
return ( return (
<> <>
<Indicator show={indicator}><MeImage onClick={handleShow} /></Indicator> <MeImage onClick={handleShow} />
<Offcanvas className={canvasStyles.offcanvas} show={show} onHide={handleClose} placement='end'> <Offcanvas className={canvasStyles.offcanvas} show={show} onHide={handleClose} placement='end'>
<Offcanvas.Header closeButton> <Offcanvas.Header closeButton>
<Offcanvas.Title><NavWalletSummary /></Offcanvas.Title> <Offcanvas.Title><NavWalletSummary /></Offcanvas.Title>
@ -53,7 +53,8 @@ export default function OffCanvas ({ me, dropNavKey }) {
<> <>
<Link href={'/' + me.name} passHref legacyBehavior> <Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}> <Dropdown.Item active={me.name === dropNavKey}>
<Indicator show={profileIndicator}>profile</Indicator> profile
{profileIndicator && <Indicator />}
</Dropdown.Item> </Dropdown.Item>
</Link> </Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior> <Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
@ -61,7 +62,8 @@ export default function OffCanvas ({ me, dropNavKey }) {
</Link> </Link>
<Link href='/wallets' passHref legacyBehavior> <Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'> <Dropdown.Item eventKey='wallets'>
<Indicator show={walletIndicator}>wallets</Indicator> wallets
{walletIndicator && <Indicator />}
</Dropdown.Item> </Dropdown.Item>
</Link> </Link>
<Link href='/credits' passHref legacyBehavior> <Link href='/credits' passHref legacyBehavior>
@ -91,7 +93,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
<Link href={`/${me?.name || 'anon'}`} className='d-flex flex-row p-2 mt-auto text-muted'> <Link href={`/${me?.name || 'anon'}`} className='d-flex flex-row p-2 mt-auto text-muted'>
<MeImage /> <MeImage />
<div className='ms-2'> <div className='ms-2'>
<Indicator show={indicator}>@{me?.name || 'anon'}</Indicator> @{me?.name || 'anon'}
</div> </div>
</Link> </Link>
</Nav> </Nav>

View File

@ -2,12 +2,9 @@ import { Nav, Navbar } from 'react-bootstrap'
import styles from '../../header.module.css' import styles from '../../header.module.css'
import { Back, NavPrice, NavSelect, NavWalletSummary, SignUpButton, hasNavSelect } from '../common' import { Back, NavPrice, NavSelect, NavWalletSummary, SignUpButton, hasNavSelect } from '../common'
import { useMe } from '@/components/me' import { useMe } from '@/components/me'
import { useCommentsNavigatorContext, CommentsNavigator } from '@/components/use-comments-navigator'
export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey }) { export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey }) {
const { me } = useMe() const { me } = useMe()
const { navigator, commentCount } = useCommentsNavigatorContext()
return ( return (
<Navbar> <Navbar>
<Nav <Nav
@ -20,7 +17,6 @@ export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNa
: ( : (
<> <>
<NavPrice className='flex-shrink-1' /> <NavPrice className='flex-shrink-1' />
<CommentsNavigator navigator={navigator} commentCount={commentCount} className='px-2' />
{me ? <NavWalletSummary /> : <SignUpButton width='fit-content' />} {me ? <NavWalletSummary /> : <SignUpButton width='fit-content' />}
</>)} </>)}
</Nav> </Nav>

View File

@ -4,12 +4,10 @@ import { Container, Nav, Navbar } from 'react-bootstrap'
import { NavPrice, MeCorner, AnonCorner, SearchItem, Back, NavWalletSummary, Brand, SignUpButton } from './common' import { NavPrice, MeCorner, AnonCorner, SearchItem, Back, NavWalletSummary, Brand, SignUpButton } from './common'
import { useMe } from '@/components/me' import { useMe } from '@/components/me'
import classNames from 'classnames' import classNames from 'classnames'
import { CommentsNavigator, useCommentsNavigatorContext } from '../use-comments-navigator'
export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey }) { export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const ref = useRef() const ref = useRef()
const { me } = useMe() const { me } = useMe()
const { navigator, commentCount } = useCommentsNavigatorContext()
useEffect(() => { useEffect(() => {
const stick = () => { const stick = () => {
@ -39,7 +37,6 @@ export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey })
<Brand className='me-1' /> <Brand className='me-1' />
<SearchItem className='me-0 ms-2' /> <SearchItem className='me-0 ms-2' />
<NavPrice /> <NavPrice />
<CommentsNavigator navigator={navigator} commentCount={commentCount} className='d-flex' />
{me ? <MeCorner dropNavKey={dropNavKey} me={me} className='d-flex' /> : <AnonCorner path={path} className='d-flex' />} {me ? <MeCorner dropNavKey={dropNavKey} me={me} className='d-flex' /> : <AnonCorner path={path} className='d-flex' />}
</Nav> </Nav>
</Navbar> </Navbar>
@ -47,12 +44,11 @@ export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey })
<Container className='px-sm-0 d-block d-md-none'> <Container className='px-sm-0 d-block d-md-none'>
<Navbar className='py-0'> <Navbar className='py-0'>
<Nav <Nav
className={classNames(styles.navbarNav)} className={classNames(styles.navbarNav, 'justify-content-between')}
activeKey={topNavKey} activeKey={topNavKey}
> >
<Back /> <Back />
<NavPrice className='flex-shrink-1' /> <NavPrice className='flex-shrink-1 flex-grow-0' />
<CommentsNavigator navigator={navigator} commentCount={commentCount} className='d-flex' />
{me ? <NavWalletSummary className='px-2' /> : <SignUpButton width='fit-content' />} {me ? <NavWalletSummary className='px-2' /> : <SignUpButton width='fit-content' />}
</Nav> </Nav>
</Navbar> </Navbar>

View File

@ -4,7 +4,8 @@ export default function preserveScroll (callback) {
// if the scroll position is at the top, we don't need to preserve it, just call the callback // if the scroll position is at the top, we don't need to preserve it, just call the callback
if (scrollTop <= 0) { if (scrollTop <= 0) {
return callback() callback()
return
} }
// get a reference element at the center of the viewport to track if content is added above it // get a reference element at the center of the viewport to track if content is added above it
@ -48,5 +49,5 @@ export default function preserveScroll (callback) {
observer.observe(document.body, { childList: true, subtree: true }) observer.observe(document.body, { childList: true, subtree: true })
return callback() callback()
} }

View File

@ -4,7 +4,7 @@ import { fixedDecimal } from '@/lib/format'
import { useMe } from './me' import { useMe } from './me'
import { PRICE } from '@/fragments/price' import { PRICE } from '@/fragments/price'
import { CURRENCY_SYMBOLS } from '@/lib/currency' import { CURRENCY_SYMBOLS } from '@/lib/currency'
import { NORMAL_POLL_INTERVAL_MS, SSR } from '@/lib/constants' import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { useBlockHeight } from './block-height' import { useBlockHeight } from './block-height'
import { useChainFee } from './chain-fee' import { useChainFee } from './chain-fee'
import { CompactLongCountdown } from './countdown' import { CompactLongCountdown } from './countdown'
@ -27,7 +27,7 @@ export function PriceProvider ({ price, children }) {
...(SSR ...(SSR
? {} ? {}
: { : {
pollInterval: NORMAL_POLL_INTERVAL_MS, pollInterval: NORMAL_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network' nextFetchPolicy: 'cache-and-network'
}) })
}) })

View File

@ -17,13 +17,6 @@ export default function PullToRefresh ({ children, className }) {
setIsPWA(androidPWA || iosPWA) setIsPWA(androidPWA || iosPWA)
} }
const clearPullDistance = () => {
setPullDistance(0)
document.body.style.marginTop = '0px'
touchStartY.current = 0
touchEndY.current = 0
}
useEffect(checkPWA, []) useEffect(checkPWA, [])
const handleTouchStart = useCallback((e) => { const handleTouchStart = useCallback((e) => {
@ -35,13 +28,6 @@ export default function PullToRefresh ({ children, className }) {
const handleTouchMove = useCallback((e) => { const handleTouchMove = useCallback((e) => {
if (touchStartY.current === 0) return if (touchStartY.current === 0) return
if (!isPWA) return if (!isPWA) return
// if we're not at the top of the page after the touch start, reset the pull distance
if (window.scrollY > 0) {
clearPullDistance()
return
}
touchEndY.current = e.touches[0].clientY touchEndY.current = e.touches[0].clientY
const distance = touchEndY.current - touchStartY.current const distance = touchEndY.current - touchStartY.current
setPullDistance(distance) setPullDistance(distance)
@ -53,7 +39,10 @@ export default function PullToRefresh ({ children, className }) {
if (touchEndY.current - touchStartY.current > REFRESH_THRESHOLD) { if (touchEndY.current - touchStartY.current > REFRESH_THRESHOLD) {
router.push(router.asPath) router.push(router.asPath)
} }
clearPullDistance() setPullDistance(0)
document.body.style.marginTop = '0px'
touchStartY.current = 0
touchEndY.current = 0
}, [router]) }, [router])
useEffect(() => { useEffect(() => {

View File

@ -1,18 +1,19 @@
import { Form, MarkdownInput } from '@/components/form' import { Form, MarkdownInput } from '@/components/form'
import styles from './reply.module.css' import styles from './reply.module.css'
import { COMMENTS } from '@/fragments/comments'
import { useMe } from './me' import { useMe } from './me'
import { forwardRef, useCallback, useEffect, useState, useRef, useMemo } from 'react' import { forwardRef, useCallback, useEffect, useState, useRef, useMemo } from 'react'
import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button' import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
import { commentsViewedAfterComment } from '@/lib/new-comments'
import { commentSchema } from '@/lib/validate' import { commentSchema } from '@/lib/validate'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { useRoot } from './root' import { useRoot } from './root'
import { CREATE_COMMENT } from '@/fragments/paidAction' import { CREATE_COMMENT } from '@/fragments/paidAction'
import { injectComment } from '@/lib/comments'
import useItemSubmit from './use-item-submit' import useItemSubmit from './use-item-submit'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import useCommentsView from './use-comments-view' import { updateAncestorsCommentCount } from '@/lib/comments'
export default forwardRef(function Reply ({ export default forwardRef(function Reply ({
item, item,
@ -29,7 +30,6 @@ export default forwardRef(function Reply ({
const showModal = useShowModal() const showModal = useShowModal()
const root = useRoot() const root = useRoot()
const sub = item?.sub || root?.sub const sub = item?.sub || root?.sub
const { markCommentViewedAt } = useCommentsView(root.id)
useEffect(() => { useEffect(() => {
if (replyOpen || quote || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) { if (replyOpen || quote || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) {
@ -51,11 +51,23 @@ export default forwardRef(function Reply ({
update (cache, { data: { upsertComment: { result, invoice } } }) { update (cache, { data: { upsertComment: { result, invoice } } }) {
if (!result) return if (!result) return
// inject the new comment into the cache cache.modify({
const injected = injectComment(cache, result) id: `Item:${parentId}`,
if (injected) { fields: {
markCommentViewedAt(result.createdAt, { ncomments: 1 }) comments (existingComments = {}) {
} const newCommentRef = cache.writeFragment({
data: result,
fragment: COMMENTS,
fragmentName: 'CommentsRecursive'
})
return {
cursor: existingComments.cursor,
comments: [newCommentRef, ...(existingComments?.comments || [])]
}
}
},
optimistic: true
})
// no lag for itemRepetition // no lag for itemRepetition
if (!item.mine && me) { if (!item.mine && me) {
@ -67,6 +79,16 @@ export default forwardRef(function Reply ({
} }
}) })
} }
const ancestors = item.path.split('.')
// update all ancestors
updateAncestorsCommentCount(cache, ancestors, 1)
// so that we don't see indicator for our own comments, we record this comments as the latest time
// but we also have record num comments, in case someone else commented when we did
const root = ancestors[0]
commentsViewedAfterComment(root, result.createdAt)
} }
}, },
onSuccessfulSubmit: (data, { resetForm }) => { onSuccessfulSubmit: (data, { resetForm }) => {

View File

@ -2,7 +2,6 @@
max-width: 600px; max-width: 600px;
padding-right: 15px; padding-right: 15px;
padding-bottom: 1rem; padding-bottom: 1rem;
margin-left: 12px;
} }
.replyButtons { .replyButtons {
@ -13,7 +12,6 @@
align-items: center; align-items: center;
line-height: 1rem; line-height: 1rem;
vertical-align: middle; vertical-align: middle;
margin-left: 12px;
} }
.replyButtons > * { .replyButtons > * {
@ -49,14 +47,4 @@
.reply .text { .reply .text {
margin-top: -1px; margin-top: -1px;
height: auto; height: auto;
}
@media screen and (min-width: 768px) {
.replyButtons {
margin-left: .99px;
}
.reply {
margin-left: .99px;
}
} }

View File

@ -15,7 +15,6 @@ import { useRouter } from 'next/router'
import { whenToFrom } from '@/lib/time' import { whenToFrom } from '@/lib/time'
import { useMe } from './me' import { useMe } from './me'
import { useField } from 'formik' import { useField } from 'formik'
import { searchSchema } from '@/lib/validate'
export default function Search ({ sub }) { export default function Search ({ sub }) {
const router = useRouter() const router = useRouter()
@ -66,7 +65,6 @@ export default function Search ({ sub }) {
<Form <Form
initial={{ q, what, sort, when, from: '', to: '' }} initial={{ q, what, sort, when, from: '', to: '' }}
onSubmit={values => search({ ...values })} onSubmit={values => search({ ...values })}
schema={searchSchema}
> >
<div className={`${styles.active} mb-3`}> <div className={`${styles.active} mb-3`}>
<SearchInput <SearchInput

View File

@ -22,6 +22,10 @@
form>.active { form>.active {
display: flex; display: flex;
pointer-events: auto; pointer-events: auto;
flex-flow: row; flex-flow: row nowrap;
align-items: center; align-items: center;
}
form>.active :global(.input-group) {
flex-flow: nowrap;
} }

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Select } from './form' import { Select } from './form'
import { EXTRA_LONG_POLL_INTERVAL_MS, SSR } from '@/lib/constants' import { EXTRA_LONG_POLL_INTERVAL, SSR } from '@/lib/constants'
import { SUBS } from '@/fragments/subs' import { SUBS } from '@/fragments/subs'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import styles from './sub-select.module.css' import styles from './sub-select.module.css'
@ -24,7 +24,7 @@ export function useSubs ({ prependSubs = DEFAULT_PREPEND_SUBS, sub, filterSubs =
const { data, refetch } = useQuery(SUBS, SSR const { data, refetch } = useQuery(SUBS, SSR
? {} ? {}
: { : {
pollInterval: EXTRA_LONG_POLL_INTERVAL_MS, pollInterval: EXTRA_LONG_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network' nextFetchPolicy: 'cache-and-network'
}) })

View File

@ -2,8 +2,11 @@ import React, { useMemo, useState } from 'react'
import Dropdown from 'react-bootstrap/Dropdown' import Dropdown from 'react-bootstrap/Dropdown'
import FormControl from 'react-bootstrap/FormControl' import FormControl from 'react-bootstrap/FormControl'
import TocIcon from '@/svgs/list-unordered.svg' import TocIcon from '@/svgs/list-unordered.svg'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { visit } from 'unist-util-visit'
import { toString } from 'mdast-util-to-string'
import { slug } from 'github-slugger'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { extractHeadings } from '@/lib/toc'
export default function Toc ({ text }) { export default function Toc ({ text }) {
const router = useRouter() const router = useRouter()
@ -11,7 +14,16 @@ export default function Toc ({ text }) {
return null return null
} }
const toc = useMemo(() => extractHeadings(text), [text]) const toc = useMemo(() => {
const tree = fromMarkdown(text)
const toc = []
visit(tree, 'heading', (node, position, parent) => {
const str = toString(node)
toc.push({ heading: str, slug: slug(str.replace(/[^\w\-\s]+/gi, '')), depth: node.depth })
})
return toc
}, [text])
if (toc.length === 0) { if (toc.length === 0) {
return null return null

View File

@ -20,7 +20,6 @@ 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 remarkToc from '@/lib/remark-toc'
const rehypeSNStyled = () => rehypeSN({ const rehypeSNStyled = () => rehypeSN({
stylers: [{ stylers: [{
@ -34,11 +33,7 @@ const rehypeSNStyled = () => rehypeSN({
}] }]
}) })
const baseRemarkPlugins = [ const remarkPlugins = [gfm, remarkUnicode, [remarkMath, { singleDollarTextMath: false }]]
gfm,
remarkUnicode,
[remarkMath, { singleDollarTextMath: false }]
]
export function SearchText ({ text }) { export function SearchText ({ text }) {
return ( return (
@ -54,9 +49,6 @@ 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 }) {
// include remarkToc if topLevel
const remarkPlugins = topLevel ? [...baseRemarkPlugins, remarkToc] : baseRemarkPlugins
// would the text overflow on the current screen size? // 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? // should we show the full text?
@ -243,9 +235,9 @@ function MediaLink ({
function Table ({ node, ...props }) { function Table ({ node, ...props }) {
return ( return (
<div className='table-responsive'> <span className='table-responsive'>
<table className='table table-bordered table-sm' {...props} /> <table className='table table-bordered table-sm' {...props} />
</div> </span>
) )
} }

View File

@ -241,7 +241,6 @@
.text table { .text table {
width: auto; width: auto;
white-space: nowrap;
} }
.text blockquote { .text blockquote {
@ -449,4 +448,4 @@
max-width: 480px; max-width: 480px;
border-radius: 13px; border-radius: 13px;
overflow: hidden; overflow: hidden;
} }

View File

@ -20,8 +20,7 @@ export default function useCanEdit (item) {
const anonEdit = !!invParams && !me && Number(item.user.id) === USER_ID.anon const anonEdit = !!invParams && !me && Number(item.user.id) === USER_ID.anon
// anonEdit should not override canEdit, but only allow edits if they aren't already allowed // anonEdit should not override canEdit, but only allow edits if they aren't already allowed
setCanEdit(canEdit => canEdit || anonEdit) setCanEdit(canEdit => canEdit || anonEdit)
// update when the hmac gets set }, [])
}, [item?.invoice?.hmac])
return [canEdit, setCanEdit, editThreshold] return [canEdit, setCanEdit, editThreshold]
} }

View File

@ -1,200 +0,0 @@
import { useCallback, useEffect, useRef, useState, startTransition, createContext, useContext } from 'react'
import styles from './comment.module.css'
import LongPressable from './long-pressable'
import { useFavicon } from './favicon'
const CommentsNavigatorContext = createContext({
navigator: {
trackNewComment: () => {},
untrackNewComment: () => {},
scrollToComment: () => {},
clearCommentRefs: () => {}
},
commentCount: 0
})
export function CommentsNavigatorProvider ({ children }) {
const value = useCommentsNavigator()
return (
<CommentsNavigatorContext.Provider value={value}>
{children}
</CommentsNavigatorContext.Provider>
)
}
export function useCommentsNavigatorContext () {
return useContext(CommentsNavigatorContext)
}
export function useCommentsNavigator () {
const { setHasNewComments } = useFavicon()
const [commentCount, setCommentCount] = useState(0)
// refs in ref to not re-render on tracking
const commentRefs = useRef([])
// ref to track if the comment count is being updated
const frameRef = useRef(null)
const navigatorRef = useRef(null)
// batch updates to the comment count
const throttleCountUpdate = useCallback(() => {
if (frameRef.current) return
// prevent multiple updates in the same frame
frameRef.current = true
window.requestAnimationFrame(() => {
const next = commentRefs.current.length
// transition to the new comment count
startTransition?.(() => setCommentCount(next))
frameRef.current = false
})
}, [])
// clear the list of refs and reset the comment count
const clearCommentRefs = useCallback(() => {
commentRefs.current = []
startTransition?.(() => setCommentCount(0))
setHasNewComments(false)
}, [])
// track a new comment
const trackNewComment = useCallback((commentRef, createdAt) => {
setHasNewComments(true)
try {
window.requestAnimationFrame(() => {
if (!commentRef?.current || !commentRef.current.isConnected) return
// dedupe
const existing = commentRefs.current.some(item => item.ref.current === commentRef.current)
if (existing) return
// find the correct insertion position to maintain sort order
const insertIndex = commentRefs.current.findIndex(item => item.createdAt > createdAt)
const newItem = { ref: commentRef, createdAt }
if (insertIndex === -1) {
// append if no newer comments found
commentRefs.current.push(newItem)
} else {
// insert at the correct position to maintain sort order
commentRefs.current.splice(insertIndex, 0, newItem)
}
throttleCountUpdate()
})
} catch {
// in the rare case of a ref being disconnected during RAF, ignore to avoid blocking UI
}
}, [throttleCountUpdate])
// remove a comment ref from the list
const untrackNewComment = useCallback((commentRef, options = {}) => {
// we just need to read a single comment to clear the favicon
setHasNewComments(false)
const { includeDescendants = false, clearOutline = false } = options
const refNode = commentRef.current
if (!refNode) return
const toRemove = commentRefs.current.filter(item => {
const node = item?.ref?.current
return includeDescendants
? node && refNode.contains(node)
: node === refNode
})
if (clearOutline) {
for (const item of toRemove) {
const node = item.ref.current
if (!node) continue
node.classList.remove(
'outline-it',
'outline-new-comment',
'outline-new-live-comment'
)
node.classList.add('outline-new-comment-unset')
}
}
if (toRemove.length) {
commentRefs.current = commentRefs.current.filter(item => !toRemove.includes(item))
throttleCountUpdate()
}
}, [throttleCountUpdate])
// scroll to the next new comment
const scrollToComment = useCallback(() => {
const list = commentRefs.current
if (!list.length) return
const ref = list[0]?.ref
const node = ref?.current
if (!node) return
// smoothly scroll to the start of the comment
node.scrollIntoView({ behavior: 'smooth', block: 'start' })
// clear the outline class after the animation ends
node.addEventListener('animationend', () => {
node.classList.remove('outline-it')
}, { once: true })
// requestAnimationFrame to ensure untracking is processed before outlining
window.requestAnimationFrame(() => {
node.classList.add('outline-it')
})
// untrack the new comment and clear the outlines
untrackNewComment(ref, { includeDescendants: true, clearOutline: true })
// if we reached the end, reset the navigator
if (list.length === 1) clearCommentRefs()
}, [clearCommentRefs, untrackNewComment])
// create the navigator object once
if (!navigatorRef.current) {
navigatorRef.current = { trackNewComment, untrackNewComment, scrollToComment, clearCommentRefs }
}
// clear the navigator on unmount
useEffect(() => {
return () => clearCommentRefs()
}, [clearCommentRefs])
return { navigator: navigatorRef.current, commentCount }
}
export function CommentsNavigator ({ navigator, commentCount, className }) {
const { scrollToComment, clearCommentRefs } = navigator
const onNext = useCallback((e) => {
// ignore if there are no new comments or if we're focused on a textarea or input
if (!commentCount || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return
// arrow right key scrolls to the next new comment
if (e.key === 'ArrowRight' && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
e.preventDefault()
scrollToComment()
}
// escape key clears the new comments navigator
if (e.key === 'Escape') clearCommentRefs()
}, [commentCount, scrollToComment, clearCommentRefs])
useEffect(() => {
if (!commentCount) return
document.addEventListener('keydown', onNext)
return () => document.removeEventListener('keydown', onNext)
}, [onNext])
return (
<LongPressable onShortPress={scrollToComment} onLongPress={clearCommentRefs}>
<aside
className={`${styles.commentNavigator} fw-bold nav-link ${className}`}
style={{ visibility: commentCount ? 'visible' : 'hidden' }}
>
<span aria-label='next comment' className={styles.navigatorButton}>
<div className={styles.newCommentDot} />
</span>
<span className=''>{commentCount}</span>
</aside>
</LongPressable>
)
}

View File

@ -1,46 +0,0 @@
import { useMutation } from '@apollo/client'
import { useCallback } from 'react'
import { UPDATE_ITEM_USER_VIEW } from '@/fragments/items'
import { commentsViewedAfterComment, commentsViewed, newComments } from '@/lib/new-comments'
import { useMe } from './me'
export default function useCommentsView (itemId, { updateCache = true } = {}) {
const { me } = useMe()
const [updateCommentsViewAt] = useMutation(UPDATE_ITEM_USER_VIEW, {
update (cache, { data: { updateCommentsViewAt } }) {
if (!updateCache || !itemId) return
cache.modify({
id: `Item:${itemId}`,
fields: { meCommentsViewedAt: () => updateCommentsViewAt }
})
}
})
const updateViewedAt = useCallback((latest, anonFallbackFn) => {
if (me?.id) {
updateCommentsViewAt({ variables: { id: Number(itemId), meCommentsViewedAt: latest } })
} else {
anonFallbackFn()
}
}, [me?.id, itemId, updateCommentsViewAt])
// update meCommentsViewedAt on comment injection
const markCommentViewedAt = useCallback((latest, { ncomments } = {}) => {
if (!latest) return
updateViewedAt(latest, () => commentsViewedAfterComment(itemId, latest, ncomments))
}, [itemId, updateViewedAt])
// update meCommentsViewedAt on item view
const markItemViewed = useCallback((item, latest) => {
if (!item || item.parentId || (item?.meCommentsViewedAt && !newComments(item))) return
const lastAt = latest || item?.lastCommentAt || item?.createdAt
const newLatest = new Date(lastAt)
updateViewedAt(newLatest, () => commentsViewed(item))
}, [updateViewedAt])
return { markCommentViewedAt, markItemViewed }
}

View File

@ -1,5 +1,5 @@
import { HAS_NOTIFICATIONS } from '@/fragments/notifications' import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import { NORMAL_POLL_INTERVAL_MS, SSR } from '@/lib/constants' import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import React, { useContext } from 'react' import React, { useContext } from 'react'
import { clearNotifications } from '@/components/serviceworker' import { clearNotifications } from '@/components/serviceworker'
@ -11,7 +11,7 @@ export function HasNewNotesProvider ({ me, children }) {
SSR SSR
? {} ? {}
: { : {
pollInterval: NORMAL_POLL_INTERVAL_MS, pollInterval: NORMAL_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network', nextFetchPolicy: 'cache-and-network',
onCompleted: ({ hasNewNotes }) => { onCompleted: ({ hasNewNotes }) => {
if (!hasNewNotes) { if (!hasNewNotes) {

View File

@ -1,114 +1,124 @@
import { useEffect, useState, useCallback } from 'react'
import { useQuery, useApolloClient } from '@apollo/client'
import { SSR } from '../lib/constants'
import preserveScroll from './preserve-scroll' import preserveScroll from './preserve-scroll'
import { GET_NEW_COMMENTS } from '../fragments/comments' import { GET_NEW_COMMENTS } from '../fragments/comments'
import { injectComment } from '../lib/comments' import { useEffect, useState } from 'react'
import useCommentsView from './use-comments-view' import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { useQuery, useApolloClient } from '@apollo/client'
import { commentsViewedAfterComment } from '../lib/new-comments'
import {
updateItemQuery,
updateCommentFragment,
getLatestCommentCreatedAt,
updateAncestorsCommentCount,
calculateDepth
} from '../lib/comments'
// live comments polling interval const POLL_INTERVAL = 1000 * 5 // 5 seconds
const POLL_INTERVAL = 1000 * 5
// live comments toggle keys
const STORAGE_DISABLE_KEY = 'disableLiveComments'
const TOGGLE_EVENT = 'liveComments:toggle'
const readStoredLatest = (key, latest) => { // prepares and creates a fragment for injection into the cache
const stored = window.sessionStorage.getItem(key) // also handles side effects like updating comment counts and viewedAt timestamps
return stored && stored > latest ? stored : latest function prepareComments (item, cache, newComment) {
const existingComments = item.comments?.comments || []
// is the incoming new comment already in item's existing comments?
// if so, we don't need to update the cache
if (existingComments.some(comment => comment.id === newComment.id)) return item
// count the new comment (+1) and its children (+ncomments)
const totalNComments = newComment.ncomments + 1
const itemHierarchy = item.path.split('.')
// update all ancestors comment count, but not the item itself
const ancestors = itemHierarchy.slice(0, -1)
updateAncestorsCommentCount(cache, ancestors, totalNComments)
// update commentsViewedAt to now, and add the number of new comments
const rootId = itemHierarchy[0]
commentsViewedAfterComment(rootId, Date.now(), totalNComments)
// add a flag to the new comment to indicate it was injected
const injectedComment = { ...newComment, injected: true }
// an item can either have a comments.comments field, or not
const payload = item.comments
? {
...item,
ncomments: item.ncomments + totalNComments,
comments: {
...item.comments,
comments: [injectedComment, ...item.comments.comments]
}
}
// when the fragment doesn't have a comments field, we just update stats fields
: {
...item,
ncomments: item.ncomments + totalNComments
}
return payload
} }
// cache new comments and return the most recent timestamp between current latest and new comment function cacheNewComments (cache, rootId, newComments, sort) {
// regardless of whether the comments were injected or not for (const newComment of newComments) {
function cacheNewComments (cache, latest, itemId, newComments, markCommentViewedAt) { const { parentId } = newComment
let injected = 0 const topLevel = Number(parentId) === Number(rootId)
const injectedLatest = newComments.reduce((latestTimestamp, newComment) => { // if the comment is a top level comment, update the item, else update the parent comment
const result = injectComment(cache, newComment, { live: true, rootId: itemId }) if (topLevel) {
// if any comment was injected, increment injected updateItemQuery(cache, rootId, sort, (item) => prepareComments(item, cache, newComment))
injected = result ? injected + 1 : injected } else {
return new Date(newComment.createdAt) > new Date(latestTimestamp) // if the comment is too deep, we can skip it
? newComment.createdAt const depth = calculateDepth(newComment.path, rootId, parentId)
: latestTimestamp if (depth > COMMENT_DEPTH_LIMIT) continue
}, latest) // inject the new comment into the parent comment's comments field
updateCommentFragment(cache, parentId, (parent) => prepareComments(parent, cache, newComment))
if (injected > 0) { }
markCommentViewedAt(injectedLatest, { ncomments: injected })
} }
return injectedLatest
} }
// fetches comments for an item that are newer than the latest comment createdAt (after), // useLiveComments fetches new comments under an item (rootId),
// injects them into cache, and keeps scroll position stable. // that are newer than the latest comment createdAt (after), and injects them into the cache.
export default function useLiveComments (itemId, after) { export default function useLiveComments (rootId, after, sort) {
const latestKey = `liveCommentsLatest:${itemId}` const latestKey = `liveCommentsLatest:${rootId}`
const { cache } = useApolloClient() const { cache } = useApolloClient()
const { markCommentViewedAt } = useCommentsView(itemId)
const [disableLiveComments] = useLiveCommentsToggle()
const [latest, setLatest] = useState(after) const [latest, setLatest] = useState(after)
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
useEffect(() => { useEffect(() => {
setLatest(readStoredLatest(latestKey, after)) if (typeof window !== 'undefined') {
const storedLatest = window.sessionStorage.getItem(latestKey)
if (storedLatest && storedLatest > after) {
setLatest(storedLatest)
} else {
setLatest(after)
}
}
// Apollo might update the cache before the page has fully rendered, causing reads of stale cached data // Apollo might update the cache before the page has fully rendered, causing reads of stale cached data
// this prevents GET_NEW_COMMENTS from producing results before the page has fully rendered // this prevents GET_NEW_COMMENTS from producing results before the page has fully rendered
setInitialized(true) setInitialized(true)
}, [itemId, after]) }, [after])
const { data } = useQuery(GET_NEW_COMMENTS, { const { data } = useQuery(GET_NEW_COMMENTS, SSR || !initialized
pollInterval: POLL_INTERVAL, ? {}
// only get comments newer than the passed latest timestamp : {
variables: { itemId, after: latest }, pollInterval: POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network', // only get comments newer than the passed latest timestamp
skip: SSR || !initialized || disableLiveComments variables: { rootId, after: latest },
}) nextFetchPolicy: 'cache-and-network'
})
useEffect(() => { useEffect(() => {
const newComments = data?.newComments?.comments if (!data?.newComments?.comments?.length) return
if (!newComments?.length) return
// directly inject new comments into the cache, preserving scroll position // directly inject new comments into the cache, preserving scroll position
// quirk: scroll is preserved even if we are not injecting new comments due to dedupe // quirk: scroll is preserved even if we are not injecting new comments due to dedupe
const injectedLatest = preserveScroll(() => cacheNewComments(cache, latest, itemId, newComments, markCommentViewedAt)) preserveScroll(() => cacheNewComments(cache, rootId, data.newComments.comments, sort))
// if we didn't process any newer comments, bail
if (new Date(injectedLatest).getTime() <= new Date(latest).getTime()) return
// update latest timestamp to the latest comment created at // update latest timestamp to the latest comment created at
// save it to session storage, to persist between client-side navigations // save it to session storage, to persist between client-side navigations
setLatest(injectedLatest) const newLatest = getLatestCommentCreatedAt(data.newComments.comments, latest)
window.sessionStorage.setItem(latestKey, injectedLatest) setLatest(newLatest)
}, [data, cache, itemId, latest, markCommentViewedAt]) if (typeof window !== 'undefined') {
} window.sessionStorage.setItem(latestKey, newLatest)
export function useLiveCommentsToggle () {
const [disableLiveComments, setDisableLiveComments] = useState(false)
useEffect(() => {
// preference: local storage
const read = () => setDisableLiveComments(window.localStorage.getItem(STORAGE_DISABLE_KEY) === 'true')
read()
// update across tabs
const onStorage = e => { if (e.key === STORAGE_DISABLE_KEY) read() }
// update this tab
const onToggle = () => read()
window.addEventListener('storage', onStorage)
window.addEventListener(TOGGLE_EVENT, onToggle)
return () => {
window.removeEventListener('storage', onStorage)
window.removeEventListener(TOGGLE_EVENT, onToggle)
} }
}, []) }, [data, cache, rootId, sort, latest])
const toggle = useCallback(() => {
const current = window.localStorage.getItem(STORAGE_DISABLE_KEY) === 'true'
window.localStorage.setItem(STORAGE_DISABLE_KEY, !current)
// trigger local event to update this tab
window.dispatchEvent(new Event(TOGGLE_EVENT))
}, [])
return [disableLiveComments, toggle]
} }

View File

@ -10,7 +10,8 @@ import { useWalletPayment } from '@/wallets/client/hooks'
this is just like useMutation with a few changes: this is just like useMutation with a few changes:
1. pays an invoice returned by the mutation 1. pays an invoice returned by the mutation
2. takes an onPaid and onPayError callback, and additional options for payment behavior 2. takes an onPaid and onPayError callback, and additional options for payment behavior
- persistOnNavigate which will keep the invoice in the cache after navigation - namely forceWaitForPayment which will always wait for the invoice to be paid
- and persistOnNavigate which will keep the invoice in the cache after navigation
3. onCompleted behaves a little differently, but analogously to useMutation, ie clientside side effects 3. onCompleted behaves a little differently, but analogously to useMutation, ie clientside side effects
of completion can still rely on it of completion can still rely on it
a. it's called before the invoice is paid for optimistic updates a. it's called before the invoice is paid for optimistic updates
@ -76,7 +77,7 @@ export function usePaidMutation (mutation,
// use the most inner callbacks/options if they exist // use the most inner callbacks/options if they exist
const { const {
onPaid, onPayError, persistOnNavigate, onPaid, onPayError, forceWaitForPayment, persistOnNavigate,
update, waitFor = inv => inv?.actionState === 'PAID', updateOnFallback update, waitFor = inv => inv?.actionState === 'PAID', updateOnFallback
} = { ...options, ...innerOptions } } = { ...options, ...innerOptions }
const ourOnCompleted = innerOnCompleted || onCompleted const ourOnCompleted = innerOnCompleted || onCompleted
@ -106,7 +107,7 @@ export function usePaidMutation (mutation,
}) })
// should we wait for the invoice to be paid? // should we wait for the invoice to be paid?
if (response?.paymentMethod === 'OPTIMISTIC') { if (response?.paymentMethod === 'OPTIMISTIC' && !forceWaitForPayment) {
// onCompleted is called before the invoice is paid for optimistic updates // onCompleted is called before the invoice is paid for optimistic updates
ourOnCompleted?.(data) ourOnCompleted?.(data)
// don't wait to pay the invoice // don't wait to pay the invoice
@ -206,8 +207,7 @@ export const paidActionCacheMods = {
fields: { fields: {
actionState: () => 'PAID', actionState: () => 'PAID',
confirmedAt: () => new Date().toISOString(), confirmedAt: () => new Date().toISOString(),
satsReceived: () => invoice.satsRequested, satsReceived: () => invoice.satsRequested
hmac: () => invoice.hmac
} }
}) })
} }

View File

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

View File

@ -15,4 +15,3 @@ Scroogey
SimpleStacker SimpleStacker
klk klk
brymut brymut
abhishandy

View File

@ -530,9 +530,8 @@ services:
- '--bitcoin-rpcuser=${RPC_USER}' - '--bitcoin-rpcuser=${RPC_USER}'
- '--bitcoin-rpcpassword=${RPC_PASS}' - '--bitcoin-rpcpassword=${RPC_PASS}'
- '--large-channels' - '--large-channels'
- '--clnrest-port=3010' - '--rest-port=3010'
- '--clnrest-host=0.0.0.0' - '--rest-host=0.0.0.0'
- '--clnrest-protocol=http'
expose: expose:
- "9735" - "9735"
ports: ports:
@ -843,27 +842,6 @@ services:
CONNECT: "localhost:${LNBITS_WEB_PORT_V1}" CONNECT: "localhost:${LNBITS_WEB_PORT_V1}"
TORDIR: "/app/.tor" TORDIR: "/app/.tor"
cpu_shares: "${CPU_SHARES_LOW}" cpu_shares: "${CPU_SHARES_LOW}"
lnpub:
image: ghcr.io/shocknet/lightning-pub@sha256:cd7bb9298d09a2cdaf1b6456ef6154e3ba24f7b902ad29cda2c08c2a4fa2af6e
container_name: lnpub
profiles:
- wallets
restart: unless-stopped
volumes:
- lnpub:/app/data
- lnd:/app/.lnd
environment:
- LND_ADDRESS=lnd:10009
- LND_CERT_PATH=/app/.lnd/tls.cert
- LND_MACAROON_PATH=/app/.lnd/data/chain/bitcoin/regtest/admin.macaroon
ports:
- ${LNPUB_PORT_1776:-1776}:1776
- ${LNPUB_PORT_1777:-1777}:1777
depends_on:
lnd:
condition: service_healthy
restart: true
cpu_shares: "${CPU_SHARES_LOW}"
dnsmasq: dnsmasq:
image: 4km3/dnsmasq:2.90-r3 image: 4km3/dnsmasq:2.90-r3
profiles: profiles:
@ -900,7 +878,6 @@ volumes:
tordata: tordata:
eclair: eclair:
dnsmasq: dnsmasq:
lnpub:
networks: networks:
default: {} default: {}

View File

@ -1,5 +1,16 @@
FROM polarlightning/clightning:24.11 FROM polarlightning/clightning:23.08
# make sure that wallet and identity is persisted across rebuilds RUN apt-get update -y \
# https://docs.corelightning.org/docs/grpc#generating-custom-certificates-optional && apt-get install -y jq wget \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN wget https://raw.githubusercontent.com/ElementsProject/lightning/v23.08/plugins/clnrest/requirements.txt \
&& pip install -r requirements.txt
# make sure that wallet and identity is persisted across rebuilds.
# server certificates contain stacker_lnd as a custom domain.
# see https://docs.corelightning.org/docs/grpc#generating-custom-certificates-optional
# since CLNRest in CLNv23.08 seems to use client certificates, they also contain stacker_lnd as a custom domain.
# see https://github.com/ElementsProject/lightning/tree/v23.08/plugins/clnrest#configuration
COPY ["./hsm_secret", "./ca-key.pem", "./ca.pem", "./server-key.pem", "./server.pem", "./client-key.pem", "./client.pem", "/home/clightning/.lightning/regtest/"] COPY ["./hsm_secret", "./ca-key.pem", "./ca.pem", "./server-key.pem", "./server.pem", "./client-key.pem", "./client.pem", "/home/clightning/.lightning/regtest/"]

View File

@ -1 +1 @@
70A2D30FE991B24B5A6BF85421BE5EF083665E84 70A2D30FE991B24B5A6BF85421BE5EF083665E81

View File

@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7LKm+zhlA/q9K MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDJM3OX9henwgjZ
tlmjLsLCSSFla0SsJmde1BQfnIZvjl093Rt1pVpRCENWjYfDub6f1ceLmWpi9fCF vBEGmnDEDuG5XXEAzYTvd3Jz8eoexkTXnXRAJdngm0gVsFBFwbGOrn4rJH1Nc5WA
PRPwRUpE/NATIWJlSO7/DHBsXRVMQiM7E2hRe16/z3OK7QV93cIPnNMq+JCeKYby xE6Z+ZIW4DjvQi8hhPQHZpWesuRrBXCdF+3dUWdCYHGSA/c+ndcQx7WZkZpCpOaT
I8qh+bLV61w611iCGaf2EjKmtHazKES3qGeO4/8IeLAv+Y7zsSfsErz8J8lOuM+M X8B2MhfdgfuoEhuGgL8oCauv/55oDsbQZliCti2SpXDt7azOenM7WiJbzMWemaJa
pMIl7ulSEK4DILJ2lc/f0/Com5JVjpe/tm4u+GuM67NKDt6WtNA5qTn+JZDWSOJ3 z3WbOsh2QZELfXWS1KmpN1jBmN7ImcbNV7IX/8zK0mZ2YvOQBY5H06m8q3lXtSFY
rtZ/+FfleAf5VH4eJ/TZKSeA0PlzSl06Suvu+R+MKT0llHl9yDfk7Afg/prABykC tMsZQuwYDNhpKXw9e6ey1GJWNZ4s8UhUG3OeLQuvL7o8XWW2oA9LplJF3dsy10cS
3OoeNLxBAgMBAAECggEAAukH3qKfu+X53cSGEkZ42sJ+VXBcifyy4MOVaIRrhrKE Mb/vrO3JAgMBAAECggEAEaAFN0GijtqLhe9Ymnh6+mHHWkKaPJTEWWngR9HgTXPW
+qBEfAjNJbNmMKNUuBNcRmnxh1ckU1OVoMy5UaQSKo5vwcxkFkUTCj4sRVMRMLVa 4gB0B46JSIixxtycG5A9Kp+Ug9j9dQR0C0SnEgvvxTs9IZBtqoUID4HoB2/qXIms
jOGZXqL3by4PktpqmnFnQgzjL6jbvsnQglVSIkCaqj7VmUid5K/3b5kK1pK5wcFY dMZ82s9fuUvThxP726teBKVJ/jroBYCGhlBz/qaiuo/BaNa1PJIYrLw0IwXEbZ79
tYgGsyBKLeiilIxMG5btXZhevnaoKSNo38Msy9rOx7WnOurw5rOmbjwjd2DxsiYl +LqtdoUTOb+ZzI04aZR82OSrOGbz7cE8uM8uNAM+xrbM11L1IUGLoLZoUyvJww4v
I5+FBBZ+oTwhob3BETQK8NhtUHAD2dFPZ0THjjU9z7n+U5C1mxpqbEytJuBllZ7g F5ZDWBe8+3D50ypCdmrL805YW1scySnA3YNLyXlfl6jE0AeYd+ZOVGhH4MfkdwXF
7QEsYb9a8cJ4PlJUFD9XK+9Wx7vaz+y/3+EJctY2EQKBgQDqQwGyhwCRyND0CSqn O/yGTF3UEhTAdtryXQw7JiLA0qTakkLQeQ8ydLZzgQKBgQDqppULwJADJsnI5b3x
n98rf5hgxMfdzAkTP9KUVarL8JA52lR9wNUSTecuaFkduTWel+gOb23dVpy9OOqt HOklQEcmg7ySMLkVmyDEL7vcesHnsUNjCWwqSF4nR4eQJcM3zfvkjZN4sgUw8aCm
4Uhc4ORNh/b6cnAomVfWK0gmPmBHzKBgL5On7E+HrM4j2i4HQuOFZY1sWUznWHcK W3G+v4mFA24xEVcuoA9EXUZuMc6XRVchV/EdgIbVCNH9+ATKkFMiaCQu11QmjkR+
Gzp1HS33NQ1+Bqa8kK0dPT/7bQKBgQDMixOude2men0noXAGwlnpMwNx3RVmaWum da4+a6bo+L6Zl9ifVoJTb/0jeQKBgQDbgcSJwDUo6b9n0Dkkwpsj6fj7Wa7jLW9a
Zm8OJsmbnjCULEWYCMU6tbUPwAfmpO3g2EDF2EjOF3exg5qQ8G8tiKk/ptZVnZaZ 8Bz3pJj5AUUWjTQ17tLl7WY8EFYGlUjie6n5lM8vQZrPCnw4ZUKBAIoxIiRwQt1j
EEm6/tHafC6ZWGh0ZqkFJVnjROzcoMhJ79eaDuNcfyErpceqh6FAUwuDtxZoZhRa tFBjP+ztJ2AUER6hBF0uFUqNNdrlryOBegnxelfb//9+SyzICjRLChiyUxEzSVSY
MyasgJILpQKBgQDFuDxshAl1AjtqXXpE9GiV/CGOO0g96YIXXxzK2etIKghw69e4 T2Maz6S40QKBgHBABjbcBEhtqsPfG3EXanS2fhLvnCq3AiNS1WbkitLbKp1ikCD1
M9MimeHvh17/+VxKOQhBdscRs5KjGrDohWZgDehjj8hi5cfIXHSIbQt+S9NThmAu ZfgILHpP3orXdb7hW+mmzHBFgPQ78qUCQ7SDPg8SaAkzCWi1ivgiQsn+K5zzv82k
DrnfV1JgvvdVx0ZmFoyWAHp24oBIGqCORSD3y+MJ7RswLUj5ilqyfQnNmQKBgCD5 mySI0ndgw8vhdLFOP2bLONvriEb1cdCpDRSxPORf8hXZrPf0U14EyazJAoGAKPkC
Rqn4PuB4nJZ38vxT9nSeka6YamvBEOaZWsvYOuYIYWJxpKJ2v3zQcCji1yM6cVKu /5dyFM303WLfl73/iWeeAwTNgTg05euV7J+7shcLl1cKHNsUYLi8cY+3DwmEjkn5
6fo6/LmklNocEh3NdM7NWiN1vNW+etmgA5LOo3vqSwTTeLtTFWpL9CqsINcMYF1y A05EkhST2fuiDkDQdhXstZki5hWFD5xTuQLwrZ/A7l33sqSG5BgzT0JzNpZHcV6f
+bnPLp3prKpBpmd6R+d7u753FHiuBBfqaRCkBWc1AoGBANZY0EBBkEcdodTjT4AP RoTIq5cQULmlhT1qX2tmCrs4pbMVaEzBOfryS1ECgYBgY+XR3ggvagkM/5jSeCT4
22BRZwg9duUa6CObeNNjKTUhX/ClVXQDJRG0jEwz4fT5wWMBKZ4NZhaMGIhNciKg 45M0UChNAFWcSVUyaDSWlu5ec/hJ7hgUsA7Wz9j8upSQZf5fKVt/k+J45WHdTNyH
OdeU2Y3tGHXemvJaNiYg88mkXZzlNeN5CRR5BASDfvP9Chs7/48ZCfgQMn25sbB6 HTcO37iFjNwRLnIEdiBoQSBEkTBChkcmxpSoMGryZEu1Ng93WIxppvlLzKeviidf
sXJd+BDdOkuQADolHWjseeqp HVqTsNNjnv2JqMKD6rxDlw==
-----END PRIVATE KEY----- -----END PRIVATE KEY-----

View File

@ -1,16 +1,16 @@
-----BEGIN CERTIFICATE REQUEST----- -----BEGIN CERTIFICATE REQUEST-----
MIICkTCCAXkCAQAwGjEYMBYGA1UEAwwPY2xuIHJlc3Qgc2VydmVyMIIBIjANBgkq MIICmDCCAYACAQAwUzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuyypvs4ZQP6vSrZZoy7CwkkhZWtErCZn ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEMMAoGA1UEAwwDY2xu
XtQUH5yGb45dPd0bdaVaUQhDVo2Hw7m+n9XHi5lqYvXwhT0T8EVKRPzQEyFiZUju MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyTNzl/YXp8II2bwRBppw
/wxwbF0VTEIjOxNoUXtev89ziu0Ffd3CD5zTKviQnimG8iPKofmy1etcOtdYghmn xA7huV1xAM2E73dyc/HqHsZE1510QCXZ4JtIFbBQRcGxjq5+KyR9TXOVgMROmfmS
9hIyprR2syhEt6hnjuP/CHiwL/mO87En7BK8/CfJTrjPjKTCJe7pUhCuAyCydpXP FuA470IvIYT0B2aVnrLkawVwnRft3VFnQmBxkgP3Pp3XEMe1mZGaQqTmk1/AdjIX
39PwqJuSVY6Xv7ZuLvhrjOuzSg7elrTQOak5/iWQ1kjid67Wf/hX5XgH+VR+Hif0 3YH7qBIbhoC/KAmrr/+eaA7G0GZYgrYtkqVw7e2sznpzO1oiW8zFnpmiWs91mzrI
2SkngND5c0pdOkrr7vkfjCk9JZR5fcg35OwH4P6awAcpAtzqHjS8QQIDAQABoDIw dkGRC311ktSpqTdYwZjeyJnGzVeyF//MytJmdmLzkAWOR9OpvKt5V7UhWLTLGULs
MAYJKoZIhvcNAQkOMSMwITAfBgNVHREEGDAWhwR/AAABgglsb2NhbGhvc3SCA2Ns GAzYaSl8PXunstRiVjWeLPFIVBtzni0Lry+6PF1ltqAPS6ZSRd3bMtdHEjG/76zt
bjANBgkqhkiG9w0BAQsFAAOCAQEAjNRTLpPVllB0talokK7HalVAfs/SBL4dUAAB yQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAIHrxnnPdlZL+ODOIajoqyGWxNju
aCpe9Q8MNlSBORgedRwfzecxpNtJ+Tb5m9nIw6vfnfRZ6mxKLW7wPyOG6t83Pivc Tigv0sKW6T+ty6sSJaHlHJynGAKKXRz8NzyUpeAwaBXTSU9Ca3PSCs4vvAvvamGT
v30rPV/wbW2DvPe8dj8K2Fyh8lWWjJCMSar1ZvFsImwXdEzCOdEU3wUYOPS+hT7+ nqEUeb9YhLe4pkBBUtpQrml6ixotids1PFf38f6965Z34S3xivucBRrGnB+hGFEz
6walQ/L2jbWtmfUibbXECoekqg/9WSpVik4iq9oBbJF6V6gk2VKskioDTX3eAC07 QKgMnQmUan9AWOqAQEzWU4sVkNzYtH1sjtRLSIVyoWD/8DiBlEL0poKq6/NjnT1z
6/P59CZJdE6vp5kjR868jt5VxTDa5MdJ8d2QFPJqZ9TmduZ36camALXbMbbB+e2+ Q6kZh5uGbeViZ9VzCM1xJVpVP1Z1oVZkMHndKf4I03IRWhW34ddhQJcja9nr2wdU
u1/ZX6DtGOnqkBWBbi3IVZeZks4lzUeXYp3/BzNeCp5F2Xfq0Q== m1Pv78262l5B0YdXTd3C73i70k7GqSusKCW+HvQAy8DmEDRxJRLGTpYNsrc=
-----END CERTIFICATE REQUEST----- -----END CERTIFICATE REQUEST-----

View File

@ -1,15 +1,14 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIICYDCCAgagAwIBAgIUcKLTD+mRsktaa/hUIb5e8INmXoQwCgYIKoZIzj0EAwIw MIICLzCCAdUCFHCi0w/pkbJLWmv4VCG+XvCDZl6BMAoGCCqGSM49BAMCMBYxFDAS
FjEUMBIGA1UEAwwLY2xuIFJvb3QgQ0EwHhcNMjUwOTAxMjMzOTE5WhcNMjYwOTAx BgNVBAMMC2NsbiBSb290IENBMB4XDTI0MTEyMDE4MDgxMVoXDTI1MTEyMDE4MDgx
MjMzOTE5WjAaMRgwFgYDVQQDDA9jbG4gcmVzdCBzZXJ2ZXIwggEiMA0GCSqGSIb3 MVowUzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
DQEBAQUAA4IBDwAwggEKAoIBAQC7LKm+zhlA/q9KtlmjLsLCSSFla0SsJmde1BQf GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEMMAoGA1UEAwwDY2xuMIIBIjANBgkq
nIZvjl093Rt1pVpRCENWjYfDub6f1ceLmWpi9fCFPRPwRUpE/NATIWJlSO7/DHBs hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyTNzl/YXp8II2bwRBppwxA7huV1xAM2E
XRVMQiM7E2hRe16/z3OK7QV93cIPnNMq+JCeKYbyI8qh+bLV61w611iCGaf2EjKm 73dyc/HqHsZE1510QCXZ4JtIFbBQRcGxjq5+KyR9TXOVgMROmfmSFuA470IvIYT0
tHazKES3qGeO4/8IeLAv+Y7zsSfsErz8J8lOuM+MpMIl7ulSEK4DILJ2lc/f0/Co B2aVnrLkawVwnRft3VFnQmBxkgP3Pp3XEMe1mZGaQqTmk1/AdjIX3YH7qBIbhoC/
m5JVjpe/tm4u+GuM67NKDt6WtNA5qTn+JZDWSOJ3rtZ/+FfleAf5VH4eJ/TZKSeA KAmrr/+eaA7G0GZYgrYtkqVw7e2sznpzO1oiW8zFnpmiWs91mzrIdkGRC311ktSp
0PlzSl06Suvu+R+MKT0llHl9yDfk7Afg/prABykC3OoeNLxBAgMBAAGjYzBhMB8G qTdYwZjeyJnGzVeyF//MytJmdmLzkAWOR9OpvKt5V7UhWLTLGULsGAzYaSl8PXun
A1UdEQQYMBaHBH8AAAGCCWxvY2FsaG9zdIIDY2xuMB0GA1UdDgQWBBS2TbrQ7FiH stRiVjWeLPFIVBtzni0Lry+6PF1ltqAPS6ZSRd3bMtdHEjG/76ztyQIDAQABMAoG
i01mjkx67gh+nBwX8zAfBgNVHSMEGDAWgBSEcmN/9rzS2hR6G7EIgsX+51N0CjAK CCqGSM49BAMCA0gAMEUCIDExbaYoitsXHdu8wgVlv7LozbNK9Te6t292ctH0dZCy
BggqhkjOPQQDAgNIADBFAiAA2ffEzwtVk5VnHbO00VWaEM3G08M8T0WwUmT1Vvy3 AiEAjg/GqpNIv01ACeK/5+HVLtba8TL6vBd9dENR4ADDoj4=
IAIhALOJl3d4R/PXiGcw8vRse8iRi+TmN3ZXB0QEfubSsfBP
-----END CERTIFICATE----- -----END CERTIFICATE-----

View File

@ -1,16 +0,0 @@
[req]
default_bits = 2048
distinguished_name = req_distinguished_name
req_extensions = req_ext
prompt = no
[req_distinguished_name]
CN = "cln rest server"
[req_ext]
subjectAltName = @alt_names
[alt_names]
IP.1 = 127.0.0.1
DNS.1 = localhost
DNS.2 = cln

View File

@ -1,16 +1,16 @@
-----BEGIN CERTIFICATE REQUEST----- -----BEGIN CERTIFICATE REQUEST-----
MIICkTCCAXkCAQAwGjEYMBYGA1UEAwwPY2xuIHJlc3Qgc2VydmVyMIIBIjANBgkq MIICmDCCAYACAQAwUzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt+KCYNwj+YJNQXlJwuFiMvJK5nU1DOcp ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEMMAoGA1UEAwwDY2xu
9dvo4gvBClzwOeaRPsB4qnkWLxZqQ8+/q+LMD7P+QUXHqI49b8SFm7FFEpCL5GEN MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt+KCYNwj+YJNQXlJwuFi
090F1+GIEUBgEqMPHP3QRfyhnI4hmo0fgGArI7BMhBZrYH7nRKSNoEgpWd8dtm9N MvJK5nU1DOcp9dvo4gvBClzwOeaRPsB4qnkWLxZqQ8+/q+LMD7P+QUXHqI49b8SF
R/jx7j9sWXtFx6LZoM6yZZXVy6DdDGNk7WR0o98fR3NtT+OzUAkCMJR45aT7SYUN m7FFEpCL5GEN090F1+GIEUBgEqMPHP3QRfyhnI4hmo0fgGArI7BMhBZrYH7nRKSN
adCVNcFKsJqVMq0HDoa4sW1u184Fg5Bx38+gDXGn4VsDXwVXNMNxmGNY7ADn+AiR oEgpWd8dtm9NR/jx7j9sWXtFx6LZoM6yZZXVy6DdDGNk7WR0o98fR3NtT+OzUAkC
q0MLGDu6B5a+Raqt+LAlvvsqbFww8wtWUHQ2gnvXZBEVS+Wi2U7VpQIDAQABoDIw MJR45aT7SYUNadCVNcFKsJqVMq0HDoa4sW1u184Fg5Bx38+gDXGn4VsDXwVXNMNx
MAYJKoZIhvcNAQkOMSMwITAfBgNVHREEGDAWhwR/AAABgglsb2NhbGhvc3SCA2Ns mGNY7ADn+AiRq0MLGDu6B5a+Raqt+LAlvvsqbFww8wtWUHQ2gnvXZBEVS+Wi2U7V
bjANBgkqhkiG9w0BAQsFAAOCAQEAWSvrwxJTRyK9mDwft4qsCCStS3Gunh7OSGeG pQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAD1yimwCgeFZwkSyzFYrzf9e68L8
da+Lcrk4NABr3SnWwl/k3HEm2ZNt4/k0eDB95ElvUUcXoC5ptlyuVup97GZyO2VG Or8Fo7w5Wa1dtiGLz8hyHL5hephUgR5xA92VJ6zIXFXWYMfOvEFvRPCmXMjnS1Y5
SqLcMlL3/vuGpkV6LGAbiLbcKKqBWwV5mP+bThMRfLJdiUpjoTrjiaHElfu8Be4e hxfLo+6NJ+U4qX301Dd3NEKCDqxYQIoRVxY+fqZBYn7vbVwimmdf3Epn8CX/tvSq
qKzHYKbmSn7I7eDixsSsySPVL8h+YIamMH68BNi2AEJJH+Yyd/J7qZbP0zu9X2lQ 49YWkq2yRDUrcOzvvt6wfG6Gv1N2Igra2op3A0LywLXkPYNFmvMOvXzA9/Tk5dGb
I09gdw49FEjx38Se8Jol7GRdzJnqFElnRHKXpR+FwtmsFMh+4Syc9Zz1fgOuBA6B LNH1aIYbgMgA9XL7g1XVHqOqIBjqTukwVUJ6o/MVx8T9eoqZQRfV1s92BkhfnugS
HR+OGRqO2jT+aINPB0x+GgKWvjt/DXxvF0tq42Q8/P4i9in95w== XQ16AO45gXNnjz0T1OjXLHzYpXIlSneo1BMIh9/eAi3d5PihBk2ss8eo3U4=
-----END CERTIFICATE REQUEST----- -----END CERTIFICATE REQUEST-----

View File

@ -1,15 +1,14 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIICXzCCAgagAwIBAgIUcKLTD+mRsktaa/hUIb5e8INmXoIwCgYIKoZIzj0EAwIw MIICLzCCAdUCFHCi0w/pkbJLWmv4VCG+XvCDZl6AMAoGCCqGSM49BAMCMBYxFDAS
FjEUMBIGA1UEAwwLY2xuIFJvb3QgQ0EwHhcNMjUwOTAxMjMyNDMxWhcNMjYwOTAx BgNVBAMMC2NsbiBSb290IENBMB4XDTI0MTEyMDE4MDE0NloXDTI1MTEyMDE4MDE0
MjMyNDMxWjAaMRgwFgYDVQQDDA9jbG4gcmVzdCBzZXJ2ZXIwggEiMA0GCSqGSIb3 NlowUzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
DQEBAQUAA4IBDwAwggEKAoIBAQC34oJg3CP5gk1BeUnC4WIy8krmdTUM5yn12+ji GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEMMAoGA1UEAwwDY2xuMIIBIjANBgkq
C8EKXPA55pE+wHiqeRYvFmpDz7+r4swPs/5BRceojj1vxIWbsUUSkIvkYQ3T3QXX hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt+KCYNwj+YJNQXlJwuFiMvJK5nU1DOcp
4YgRQGASow8c/dBF/KGcjiGajR+AYCsjsEyEFmtgfudEpI2gSClZ3x22b01H+PHu 9dvo4gvBClzwOeaRPsB4qnkWLxZqQ8+/q+LMD7P+QUXHqI49b8SFm7FFEpCL5GEN
P2xZe0XHotmgzrJlldXLoN0MY2TtZHSj3x9Hc21P47NQCQIwlHjlpPtJhQ1p0JU1 090F1+GIEUBgEqMPHP3QRfyhnI4hmo0fgGArI7BMhBZrYH7nRKSNoEgpWd8dtm9N
wUqwmpUyrQcOhrixbW7XzgWDkHHfz6ANcafhWwNfBVc0w3GYY1jsAOf4CJGrQwsY R/jx7j9sWXtFx6LZoM6yZZXVy6DdDGNk7WR0o98fR3NtT+OzUAkCMJR45aT7SYUN
O7oHlr5Fqq34sCW++ypsXDDzC1ZQdDaCe9dkERVL5aLZTtWlAgMBAAGjYzBhMB8G adCVNcFKsJqVMq0HDoa4sW1u184Fg5Bx38+gDXGn4VsDXwVXNMNxmGNY7ADn+AiR
A1UdEQQYMBaHBH8AAAGCCWxvY2FsaG9zdIIDY2xuMB0GA1UdDgQWBBRJm0s9JXB+ q0MLGDu6B5a+Raqt+LAlvvsqbFww8wtWUHQ2gnvXZBEVS+Wi2U7VpQIDAQABMAoG
zXHKjB4oTDS31a+h2zAfBgNVHSMEGDAWgBSEcmN/9rzS2hR6G7EIgsX+51N0CjAK CCqGSM49BAMCA0gAMEUCIDcuZHxYHgEr0PHIR6JJF72T7c1JccvIvjl0JIqjjwwq
BggqhkjOPQQDAgNHADBEAiBfwGNj4RZBTmIb44Nk1nTD/r7fIkEeDgkgxkOiAiQ9 AiEAtzbdnTMuJP16csHt+RrSsIVGUy5G5byI/M0RtIwyQGQ=
twIgK+vJ3dxP21cH5ye2ODbMqe0RC/3FoyJegqIUocvTu6s=
-----END CERTIFICATE----- -----END CERTIFICATE-----

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -8,7 +8,7 @@ sub: meta
_To quickly browse through this FAQ page, click the chapters icon in the top-right corner. This will let you scroll through all chapters or search for a particular topic within this page._ _To quickly browse through this FAQ page, click the chapters icon in the top-right corner. This will let you scroll through all chapters or search for a particular topic within this page._
last updated: August 13, 2025 last updated: July 16, 2025
--- ---
@ -46,7 +46,7 @@ See the [section about territories](#territories) for details.
### Do I need bitcoin to use Stacker News? ### Do I need bitcoin to use Stacker News?
No. You don't need bitcoin to get started. You can create your bio for free (with limited visibility) and earn Cowboy Credits (CCs) from zaps to cover fees. Posts and comments incur a small fee to prevent spam and to encourage quality contributions. Many stackers earn enough from their posts and comments to continue posting on the site indefinitely without ever buying CCs with sats. No. Every new stacker can post or comment for free (with limited visibility) while they earn their first few CCs or sats. After a stacker has gained a balance, subsequent posts and comments will incur a small fee to prevent spam and to encourage quality contributions. Many stackers earn enough from their posts and comments to continue posting on the site indefinitely without ever buying CCs with sats.
[Post and comment fees vary depending on the territory](#why-does-it-cost-more-to-post-in-some-territories). [Post and comment fees vary depending on the territory](#why-does-it-cost-more-to-post-in-some-territories).

View File

@ -47,7 +47,7 @@ export const COMMENT_FIELDS = gql`
otsHash otsHash
ncomments ncomments
nDirectComments nDirectComments
live @client injected @client
imgproxyUrls imgproxyUrls
rel rel
apiKey apiKey
@ -55,7 +55,6 @@ export const COMMENT_FIELDS = gql`
id id
actionState actionState
confirmedAt confirmedAt
hmac
} }
cost cost
} }
@ -95,7 +94,6 @@ export const COMMENT_FIELDS_NO_CHILD_COMMENTS = gql`
commentCredits commentCredits
mine mine
otsHash otsHash
live @client
imgproxyUrls imgproxyUrls
rel rel
apiKey apiKey
@ -103,7 +101,6 @@ export const COMMENT_FIELDS_NO_CHILD_COMMENTS = gql`
id id
actionState actionState
confirmedAt confirmedAt
hmac
} }
cost cost
} }
@ -155,16 +152,6 @@ export const COMMENTS = gql`
comments { comments {
comments { comments {
...CommentFields ...CommentFields
comments {
comments {
...CommentFields
comments {
comments {
...CommentFields
}
}
}
}
} }
} }
} }
@ -177,19 +164,48 @@ export const COMMENTS = gql`
} }
}` }`
export const HAS_COMMENTS = gql` export const COMMENT_WITH_NEW_RECURSIVE = gql`
fragment HasComments on Item { ${COMMENT_FIELDS}
comments ${COMMENTS}
}
` fragment CommentWithNewRecursive on Item {
...CommentFields
export const GET_NEW_COMMENTS = gql` comments {
${COMMENT_FIELDS_NO_CHILD_COMMENTS}
query GetNewComments($itemId: ID, $after: Date) {
newComments(itemId: $itemId, after: $after) {
comments { comments {
...CommentFieldsNoChildComments ...CommentsRecursive
}
}
}
`
export const COMMENT_WITH_NEW_LIMITED = gql`
${COMMENT_FIELDS}
fragment CommentWithNewLimited on Item {
...CommentFields
comments {
comments {
...CommentFields
}
}
}
`
export const COMMENT_WITH_NEW_MINIMAL = gql`
${COMMENT_FIELDS}
fragment CommentWithNewMinimal on Item {
...CommentFields
}
`
export const GET_NEW_COMMENTS = gql`
${COMMENTS}
query GetNewComments($rootId: ID, $after: Date) {
newComments(rootId: $rootId, after: $after) {
comments {
...CommentsRecursive
} }
} }
} }

View File

@ -81,7 +81,6 @@ export const ITEM_FIELDS = gql`
confirmedAt confirmedAt
} }
cost cost
meCommentsViewedAt
}` }`
export const ITEM_FULL_FIELDS = gql` export const ITEM_FULL_FIELDS = gql`
@ -92,15 +91,12 @@ export const ITEM_FULL_FIELDS = gql`
text text
root { root {
id id
createdAt
title title
bounty bounty
bountyPaidTo bountyPaidTo
subName subName
mine mine
ncomments ncomments
lastCommentAt
meCommentsViewedAt
user { user {
id id
name name
@ -215,9 +211,3 @@ export const RELATED_ITEMS_WITH_ITEM = gql`
} }
} }
` `
export const UPDATE_ITEM_USER_VIEW = gql`
mutation updateCommentsViewAt($id: ID!, $meCommentsViewedAt: Date!) {
updateCommentsViewAt(id: $id, meCommentsViewedAt: $meCommentsViewedAt)
}
`

View File

@ -131,6 +131,11 @@ export const SET_SETTINGS = gql`
} }
}` }`
export const DELETE_WALLET = gql`
mutation removeWallet {
removeWallet
}`
export const NAME_QUERY = gql` export const NAME_QUERY = gql`
query nameAvailable($name: String!) { query nameAvailable($name: String!) {
nameAvailable(name: $name) nameAvailable(name: $name)

View File

@ -324,9 +324,9 @@ function getClient (uri) {
} }
} }
}, },
live: { injected: {
read (live) { read (injected) {
return live || false return injected || false
} }
}, },
meAnonSats: { meAnonSats: {

View File

@ -196,10 +196,11 @@ export async function multiAuthMiddleware (req, res) {
} }
const ok = await checkMultiAuthCookies(req, res) const ok = await checkMultiAuthCookies(req, res)
if (ok) { if (!ok) {
await refreshMultiAuthCookies(req, res)
} else {
await resetMultiAuthCookies(req, res) await resetMultiAuthCookies(req, res)
return switchSessionCookie(req)
} }
await refreshMultiAuthCookies(req, res)
return switchSessionCookie(req) return switchSessionCookie(req)
} }

View File

@ -1,8 +1,8 @@
import crossFetch from 'cross-fetch' import fetch from 'cross-fetch'
import crypto from 'crypto' import crypto from 'crypto'
import { getAgent } from '@/lib/proxy' import { getAgent } from '@/lib/proxy'
import { assertContentTypeJson, assertResponseOk } from './url' import { assertContentTypeJson, assertResponseOk } from './url'
import { fetchWithTimeout, FetchTimeoutError } from './fetch' import { FetchTimeoutError } from './fetch'
import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from './constants' import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from './constants'
export const createInvoice = async ({ msats, description, expiry }, { socket, rune, cert }, { signal }) => { export const createInvoice = async ({ msats, description, expiry }, { socket, rune, cert }, { signal }) => {
@ -13,9 +13,15 @@ export const createInvoice = async ({ msats, description, expiry }, { socket, ru
const method = 'POST' const method = 'POST'
let res let res
try { try {
res = await crossFetch(url, { res = await fetch(url, {
method, method,
headers: headers(rune), headers: {
'Content-Type': 'application/json',
Rune: rune,
// can be any node id, only required for CLN v23.08 and below
// see https://docs.corelightning.org/docs/rest#server
nodeId: '02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490'
},
agent, agent,
body: JSON.stringify({ body: JSON.stringify({
// CLN requires a unique label for every invoice // CLN requires a unique label for every invoice
@ -46,42 +52,6 @@ export const createInvoice = async ({ msats, description, expiry }, { socket, ru
return inv return inv
} }
export const sendPayment = async (bolt11, { socket, rune }, { signal }) => {
// XXX we don't ask for the CA certificate because the browser's fetch API doesn't support http agents to override it.
// Therefore, CLNRest send will only work with common CA certificates.
// API documentation
// https://docs.corelightning.org/reference/pay
const url = new URL(
'/v1/pay',
process.env.NODE_ENV === 'development' ? `http://${socket}` : `https://${socket}`)
const method = 'POST'
const res = await fetchWithTimeout(url, {
method,
headers: headers(rune),
body: JSON.stringify({ bolt11 }),
signal
})
assertResponseOk(res, { method })
assertContentTypeJson(res, { method })
const result = await res.json()
if (result.error) {
throw new Error(result.error.message)
}
return result.payment_preimage
}
function headers (rune) {
const headers = new Headers()
headers.append('Content-Type', 'application/json')
headers.append('Rune', rune)
// can be any node id, only required for CLN v23.08 and below
// see https://docs.corelightning.org/docs/rest#server
headers.append('nodeId', '02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490')
return headers
}
// https://github.com/clams-tech/rune-decoder/blob/57c2e76d1ef9ab7336f565b99de300da1c7b67ce/src/index.ts // https://github.com/clams-tech/rune-decoder/blob/57c2e76d1ef9ab7336f565b99de300da1c7b67ce/src/index.ts
export const decodeRune = (rune) => { export const decodeRune = (rune) => {
const runeBinary = Base64Binary.decode(rune) const runeBinary = Base64Binary.decode(rune)

View File

@ -1,87 +1,91 @@
import { COMMENTS, HAS_COMMENTS } from '../fragments/comments' import { COMMENT_WITH_NEW_RECURSIVE, COMMENT_WITH_NEW_LIMITED, COMMENT_WITH_NEW_MINIMAL } from '../fragments/comments'
import { ITEM_FULL } from '../fragments/items'
// adds a comment to the cache, under its parent item // updates the ncomments field of all ancestors of an item/comment in the cache
function cacheComment (cache, newComment, { live = false }) { export function updateAncestorsCommentCount (cache, ancestors, increment) {
return cache.modify({ // update all ancestors
id: `Item:${newComment.parentId}`,
fields: {
comments: (existingComments = {}, { readField }) => {
// if the comment already exists, return
if (existingComments?.comments?.some(c => readField('id', c) === newComment.id)) return existingComments
// we need to make sure we're writing a fragment that matches the comments query (comments and count fields)
const newCommentRef = cache.writeFragment({
data: {
comments: {
comments: []
},
ncomments: 0,
nDirectComments: 0,
...newComment,
live
},
fragment: COMMENTS,
fragmentName: 'CommentsRecursive'
})
return {
cursor: existingComments.cursor,
comments: [newCommentRef, ...(existingComments?.comments || [])]
}
}
},
optimistic: true
})
}
// handles cache injection and side-effects for both live and non-live comments
export function injectComment (cache, newComment, { live = false, rootId } = {}) {
// if live and a reply (not top level), check if the parent has comments
const hasComments = live && !(Number(rootId) === Number(newComment.parentId))
? !!(cache.readFragment({
id: `Item:${newComment.parentId}`,
fragment: HAS_COMMENTS
}))
// if not live, we can assume the parent has the comments field since user replied to it
: true
const updated = hasComments && cacheComment(cache, newComment, { live })
// run side effects if injection succeeded or if injecting live comment into SSR item without comments field
if (updated || (live && !hasComments)) {
// update all ancestors comment count, excluding the comment itself
const ancestors = newComment.path.split('.').slice(0, -1)
updateAncestorsCommentCount(cache, ancestors)
return true
}
return false
}
// updates the ncomments and nDirectComments fields of all ancestors of an item/comment in the cache
function updateAncestorsCommentCount (cache, ancestors, { ncomments = 1, nDirectComments = 1 } = {}) {
// update nDirectComments of immediate parent
cache.modify({
id: `Item:${ancestors[ancestors.length - 1]}`,
fields: {
nDirectComments (existingNDirectComments = 0) {
return existingNDirectComments + nDirectComments
}
},
optimistic: true
})
// update ncomments of all ancestors
ancestors.forEach(id => { ancestors.forEach(id => {
cache.modify({ cache.modify({
id: `Item:${id}`, id: `Item:${id}`,
fields: { fields: {
ncomments (existingNComments = 0) { ncomments (existingNComments = 0) {
return existingNComments + ncomments return existingNComments + increment
} }
}, },
optimistic: true optimistic: true
}) })
}) })
} }
// updates the item query in the cache
// this is used by live comments to update a top level item's comments field
export function updateItemQuery (cache, id, sort, fn) {
cache.updateQuery({
query: ITEM_FULL,
// updateQuery needs the correct variables to update the correct item
// the Item query might have the router.query.sort in the variables, so we need to pass it in if it exists
variables: sort ? { id, sort } : { id }
}, (data) => {
if (!data) return data
return { item: fn(data.item) }
})
}
// updates a comment fragment in the cache, with fallbacks for comments lacking CommentsRecursive or Comments altogether
export function updateCommentFragment (cache, id, fn) {
let result = cache.updateFragment({
id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_RECURSIVE,
fragmentName: 'CommentWithNewRecursive'
}, (data) => {
if (!data) return data
return fn(data)
})
// sometimes comments can start to reach their depth limit, and lack adherence to the CommentsRecursive fragment
// for this reason, we update the fragment with a limited version that only includes the CommentFields fragment
if (!result) {
result = cache.updateFragment({
id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_LIMITED,
fragmentName: 'CommentWithNewLimited'
}, (data) => {
if (!data) return data
return fn(data)
})
}
// at the deepest level, the comment can't have any children, here we update only the newComments field.
if (!result) {
result = cache.updateFragment({
id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_MINIMAL,
fragmentName: 'CommentWithNewMinimal'
}, (data) => {
if (!data) return data
return fn(data)
})
}
return result
}
export function calculateDepth (path, rootId, parentId) {
// calculate depth by counting path segments from root to parent
const pathSegments = path.split('.')
const rootIndex = pathSegments.indexOf(rootId.toString())
const parentIndex = pathSegments.indexOf(parentId.toString())
// depth is the distance from root to parent in the path
const depth = parentIndex - rootIndex
return depth
}
// finds the most recent createdAt timestamp from an array of comments
export function getLatestCommentCreatedAt (comments, latest) {
return comments.reduce(
(max, { createdAt }) => (createdAt > max ? createdAt : max),
latest
)
}

View File

@ -42,7 +42,7 @@ export const BOUNTY_MAX = 10000000
export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL'] export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL']
export const TERRITORY_BILLING_TYPES = ['MONTHLY', 'YEARLY', 'ONCE'] export const TERRITORY_BILLING_TYPES = ['MONTHLY', 'YEARLY', 'ONCE']
export const TERRITORY_GRACE_DAYS = 5 export const TERRITORY_GRACE_DAYS = 5
export const COMMENT_DEPTH_LIMIT = 8 export const COMMENT_DEPTH_LIMIT = 6
export const COMMENTS_LIMIT = 50 export const COMMENTS_LIMIT = 50
export const FULL_COMMENTS_THRESHOLD = 200 export const FULL_COMMENTS_THRESHOLD = 200
export const COMMENTS_OF_COMMENT_LIMIT = 2 export const COMMENTS_OF_COMMENT_LIMIT = 2
@ -59,14 +59,13 @@ export const INV_PENDING_LIMIT = 100
export const USER_ID = { export const USER_ID = {
k00b: 616, k00b: 616,
ek: 6030, ek: 6030,
sox: 26458,
sn: 4502, sn: 4502,
anon: 27, anon: 27,
ad: 9, ad: 9,
delete: 106, delete: 106,
saloon: 17226 saloon: 17226
} }
export const SN_ADMIN_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sox, USER_ID.sn] export const SN_ADMIN_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sn]
export const SN_NO_REWARDS_IDS = [USER_ID.anon, USER_ID.sn, USER_ID.saloon] export const SN_NO_REWARDS_IDS = [USER_ID.anon, USER_ID.sn, USER_ID.saloon]
export const MAX_POLL_NUM_CHOICES = 10 export const MAX_POLL_NUM_CHOICES = 10
export const MIN_POLL_NUM_CHOICES = 2 export const MIN_POLL_NUM_CHOICES = 2
@ -184,18 +183,16 @@ export const LOST_BLURBS = {
export const ADMIN_ITEMS = [ export const ADMIN_ITEMS = [
// FAQ, old privacy policy, changelog, content guidelines, tos, new privacy policy, copyright policy // FAQ, old privacy policy, changelog, content guidelines, tos, new privacy policy, copyright policy
349, 76894, 78763, 81862, 338393, 338369, 338453, 349, 76894, 78763, 81862, 338393, 338369, 338453
// wallet guides: LNbits, phoenixd, Alby Hub, Coinos
1212223, 1212375, 1215565, 1230105
] ]
export const INVOICE_RETENTION_DAYS = 7 export const INVOICE_RETENTION_DAYS = 7
export const JIT_INVOICE_TIMEOUT_MS = 180_000 export const JIT_INVOICE_TIMEOUT_MS = 180_000
export const FAST_POLL_INTERVAL_MS = Number(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL_MS) export const FAST_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL)
export const NORMAL_POLL_INTERVAL_MS = Number(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS) export const NORMAL_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL)
export const LONG_POLL_INTERVAL_MS = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL_MS) export const LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL)
export const EXTRA_LONG_POLL_INTERVAL_MS = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS) export const EXTRA_LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL)
export const ZAP_UNDO_DELAY_MS = 5_000 export const ZAP_UNDO_DELAY_MS = 5_000

View File

@ -22,14 +22,7 @@ export function commentsViewedAfterComment (rootId, createdAt, ncomments = 1) {
} }
export function newComments (item) { export function newComments (item) {
if (!item.parentId && item.lastCommentAt) { if (!item.parentId) {
// if logged, prefer server-tracked view
if (item.meCommentsViewedAt) {
const viewedAt = new Date(item.meCommentsViewedAt).getTime()
return viewedAt < new Date(item.lastCommentAt).getTime()
}
// anon fallback
const viewedAt = commentsViewedAt(item.id) const viewedAt = commentsViewedAt(item.id)
const viewNum = commentsViewedNum(item.id) const viewNum = commentsViewedNum(item.id)

View File

@ -1,61 +0,0 @@
import { SKIP, visit } from 'unist-util-visit'
import { extractHeadings } from './toc'
export default function remarkToc () {
return function transformer (tree) {
const headings = extractHeadings(tree)
visit(tree, 'paragraph', (node, index, parent) => {
if (
node.children?.length === 1 &&
node.children[0].type === 'text' &&
node.children[0].value.trim() === '{:toc}'
) {
parent.children.splice(index, 1, buildToc(headings))
return [SKIP, index]
}
})
}
}
function buildToc (headings) {
const root = { type: 'list', ordered: false, spread: false, children: [] }
const stack = [{ depth: 0, node: root }] // holds the current chain of parents
for (const { heading, slug, depth } of headings) {
// walk up the stack to find the parent of the current heading
while (stack.length && depth <= stack[stack.length - 1].depth) {
stack.pop()
}
let parent = stack[stack.length - 1].node
// if the parent is a li, gets its child ul
if (parent.type === 'listItem') {
let ul = parent.children.find(c => c.type === 'list')
if (!ul) {
ul = { type: 'list', ordered: false, spread: false, children: [] }
parent.children.push(ul)
}
parent = ul
}
// build the li from the current heading
const listItem = {
type: 'listItem',
spread: false,
children: [{
type: 'paragraph',
children: [{
type: 'link',
url: `#${slug}`,
children: [{ type: 'text', value: heading }]
}]
}]
}
parent.children.push(listItem)
stack.push({ depth, node: listItem })
}
return root
}

View File

@ -1,23 +0,0 @@
import { fromMarkdown } from 'mdast-util-from-markdown'
import { visit } from 'unist-util-visit'
import { toString } from 'mdast-util-to-string'
import { slug } from 'github-slugger'
export function extractHeadings (markdownOrTree) {
const tree = typeof markdownOrTree === 'string'
? fromMarkdown(markdownOrTree)
: markdownOrTree
const headings = []
visit(tree, 'heading', node => {
const str = toString(node)
headings.push({
heading: str,
slug: slug(str.replace(/[^\w\-\s]+/gi, '')),
depth: node.depth
})
})
return headings
}

View File

@ -78,17 +78,6 @@ export function parseInternalLinks (href) {
return {} return {}
} }
export function parseYoutubeStart (t) {
// https://stackoverflow.com/questions/17379268/youtube-dropped-t-start-time-support-in-direct-url-and-embed-videos
// https://developers.google.com/youtube/player_parameters#start
if (!t || !t.match(/^([0-9]+[smh])+$/g)) return t
let r = 0
for (const m of t.matchAll(/([0-9]+)([smh])/g)) {
r += parseInt(m[1]) * Math.pow(60, 'smh'.indexOf(m[2]))
}
return r.toString()
}
export function parseEmbedUrl (href) { export function parseEmbedUrl (href) {
if (!href) return null if (!href) return null
@ -143,7 +132,7 @@ export function parseEmbedUrl (href) {
id: searchParams.get('v'), id: searchParams.get('v'),
meta: { meta: {
href, href,
start: parseYoutubeStart(searchParams.get('t')) start: searchParams.get('t')
} }
} }
} }
@ -163,7 +152,7 @@ export function parseEmbedUrl (href) {
id: pathname.slice(1), // remove leading slash id: pathname.slice(1), // remove leading slash
meta: { meta: {
href, href,
start: parseYoutubeStart(searchParams.get('t')) start: searchParams.get('t')
} }
} }
} }

View File

@ -116,10 +116,6 @@ async function subHasPostType (name, type, { client, models }) {
return !!(sub?.postTypes?.includes(type)) return !!(sub?.postTypes?.includes(type))
} }
export const searchSchema = object({
q: string().trim().max(100, 'must be at most 100 characters')
})
export function advPostSchemaMembers ({ me, existingBoost = 0, ...args }) { export function advPostSchemaMembers ({ me, existingBoost = 0, ...args }) {
const boostMin = existingBoost || BOOST_MIN const boostMin = existingBoost || BOOST_MIN
return { return {

View File

@ -1,6 +1,6 @@
const { withPlausibleProxy } = require('next-plausible') const { withPlausibleProxy } = require('next-plausible')
const { InjectManifest } = require('workbox-webpack-plugin') const { InjectManifest } = require('workbox-webpack-plugin')
const CopyPlugin = require('copy-webpack-plugin') const { generatePrecacheManifest } = require('./sw/build.js')
const webpack = require('webpack') const webpack = require('webpack')
let isProd = process.env.NODE_ENV === 'production' let isProd = process.env.NODE_ENV === 'production'
@ -215,24 +215,15 @@ module.exports = withPlausibleProxy()({
}, },
webpack: (config, { isServer, dev, defaultLoaders }) => { webpack: (config, { isServer, dev, defaultLoaders }) => {
if (isServer) { if (isServer) {
generatePrecacheManifest()
const workboxPlugin = new InjectManifest({ const workboxPlugin = new InjectManifest({
include: [/\/(icons|maskable|splash)\//, /\.(webp|ttf|woff|woff2)$/], // ignore the precached manifest which includes the webpack assets
// since they are not useful to us
exclude: [/.*/],
// by default, webpack saves service worker at .next/server/
swDest: '../../public/sw.js', swDest: '../../public/sw.js',
swSrc: './sw/index.js', swSrc: './sw/index.js',
webpackCompilationPlugins: [ webpackCompilationPlugins: [
// we want to precache these static assets so we copy them to include them in the webpack pipeline
// so InjectManifest can inject them into the service worker manifest
new CopyPlugin({
patterns: [
{ from: 'public/icons', to: '../icons' },
{ from: 'public/maskable', to: '../maskable' },
{ from: 'public/splash', to: '../splash' },
{ from: 'public/waiting.webp', to: '../waiting.webp' },
{ from: 'public/Lightningvolt-xoqm.ttf', to: '../Lightningvolt-xoqm.ttf' },
{ from: 'public/Lightningvolt-xoqm.woff', to: '../Lightningvolt-xoqm.woff' },
{ from: 'public/Lightningvolt-xoqm.woff2', to: '../Lightningvolt-xoqm.woff2' }
]
}),
// this is need to allow the service worker to access these environment variables // this is need to allow the service worker to access these environment variables
// from lib/constants.js // from lib/constants.js
new webpack.DefinePlugin({ new webpack.DefinePlugin({
@ -241,10 +232,10 @@ module.exports = withPlausibleProxy()({
'process.env.NEXT_PUBLIC_MEDIA_URL': JSON.stringify(process.env.NEXT_PUBLIC_MEDIA_URL), 'process.env.NEXT_PUBLIC_MEDIA_URL': JSON.stringify(process.env.NEXT_PUBLIC_MEDIA_URL),
'process.env.NEXT_PUBLIC_MEDIA_DOMAIN': JSON.stringify(process.env.NEXT_PUBLIC_MEDIA_DOMAIN), 'process.env.NEXT_PUBLIC_MEDIA_DOMAIN': JSON.stringify(process.env.NEXT_PUBLIC_MEDIA_DOMAIN),
'process.env.NEXT_PUBLIC_URL': JSON.stringify(process.env.NEXT_PUBLIC_URL), 'process.env.NEXT_PUBLIC_URL': JSON.stringify(process.env.NEXT_PUBLIC_URL),
'process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL_MS': JSON.stringify(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL_MS), 'process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL': JSON.stringify(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL),
'process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS': JSON.stringify(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS), 'process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL': JSON.stringify(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL),
'process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL_MS': JSON.stringify(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL_MS), 'process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL': JSON.stringify(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL),
'process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS': JSON.stringify(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS), 'process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL': JSON.stringify(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL),
'process.env.SANCTIONED_COUNTRY_CODES': JSON.stringify(process.env.SANCTIONED_COUNTRY_CODES), 'process.env.SANCTIONED_COUNTRY_CODES': JSON.stringify(process.env.SANCTIONED_COUNTRY_CODES),
'process.env.NEXT_IS_EXPORT_WORKER': 'true' 'process.env.NEXT_IS_EXPORT_WORKER': 'true'
}) })

347
package-lock.json generated
View File

@ -20,7 +20,6 @@
"@nostr-dev-kit/ndk-wallet": "^0.5.0", "@nostr-dev-kit/ndk-wallet": "^0.5.0",
"@opensearch-project/opensearch": "^2.12.0", "@opensearch-project/opensearch": "^2.12.0",
"@prisma/client": "^5.20.0", "@prisma/client": "^5.20.0",
"@shocknet/clink-sdk": "^1.4.0",
"@slack/web-api": "^7.6.0", "@slack/web-api": "^7.6.0",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@yudiel/react-qr-scanner": "^2.0.8", "@yudiel/react-qr-scanner": "^2.0.8",
@ -36,7 +35,6 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clipboard-copy": "^4.0.1", "clipboard-copy": "^4.0.1",
"cookie": "^1.0.1", "cookie": "^1.0.1",
"copy-webpack-plugin": "^13.0.1",
"cross-fetch": "^4.0.0", "cross-fetch": "^4.0.0",
"csv-parser": "^3.0.0", "csv-parser": "^3.0.0",
"domino": "^2.1.6", "domino": "^2.1.6",
@ -3237,31 +3235,11 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"dependencies": { "dependencies": {
"string-width": "^5.1.2", "string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0", "string-width-cjs": "npm:string-width@^4.2.0",
@ -3278,6 +3256,7 @@
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -3289,6 +3268,7 @@
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -3299,12 +3279,14 @@
"node_modules/@isaacs/cliui/node_modules/emoji-regex": { "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true
}, },
"node_modules/@isaacs/cliui/node_modules/string-width": { "node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"dependencies": { "dependencies": {
"eastasianwidth": "^0.2.0", "eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2", "emoji-regex": "^9.2.2",
@ -3321,6 +3303,7 @@
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-regex": "^6.0.1" "ansi-regex": "^6.0.1"
}, },
@ -3335,6 +3318,7 @@
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-styles": "^6.1.0", "ansi-styles": "^6.1.0",
"string-width": "^5.0.1", "string-width": "^5.0.1",
@ -5284,137 +5268,6 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@shocknet/clink-sdk": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@shocknet/clink-sdk/-/clink-sdk-1.4.0.tgz",
"integrity": "sha512-J0PWE8CVRJrFF1Zi/UhChhvOrlmDj7LRJTpR6rbHlFPmjC5TGIW6891tVWWv+JmUR0jzez9QHFrHnc8DgIJYCQ==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.8.0",
"@scure/base": "^1.2.5",
"nostr-tools": "^2.13.0",
"rimraf": "^6.0.1",
"typescript": "^5.8.3"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/glob": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.0.3",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/jackspeak": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/lru-cache": {
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz",
"integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==",
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/minimatch": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
"license": "ISC",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/path-scurry": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/rimraf": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
"integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==",
"license": "ISC",
"dependencies": {
"glob": "^11.0.0",
"package-json-from-dist": "^1.0.0"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@sinclair/typebox": { "node_modules/@sinclair/typebox": {
"version": "0.27.8", "version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@ -6655,23 +6508,6 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-keywords": { "node_modules/ajv-keywords": {
"version": "3.5.2", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
@ -8212,60 +8048,6 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
}, },
"node_modules/copy-webpack-plugin": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz",
"integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==",
"license": "MIT",
"dependencies": {
"glob-parent": "^6.0.1",
"normalize-path": "^3.0.0",
"schema-utils": "^4.2.0",
"serialize-javascript": "^6.0.2",
"tinyglobby": "^0.2.12"
},
"engines": {
"node": ">= 18.12.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^5.1.0"
}
},
"node_modules/copy-webpack-plugin/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
"peerDependencies": {
"ajv": "^8.8.2"
}
},
"node_modules/copy-webpack-plugin/node_modules/schema-utils": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
"ajv-formats": "^2.1.1",
"ajv-keywords": "^5.1.0"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.38.1", "version": "3.38.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.1.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.1.tgz",
@ -8454,10 +8236,10 @@
} }
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"license": "MIT", "dev": true,
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
"shebang-command": "^2.0.0", "shebang-command": "^2.0.0",
@ -9139,7 +8921,8 @@
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true
}, },
"node_modules/ecc-jsbn": { "node_modules/ecc-jsbn": {
"version": "0.1.2", "version": "0.1.2",
@ -10451,23 +10234,6 @@
"bser": "2.1.1" "bser": "2.1.1"
} }
}, },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -10611,12 +10377,12 @@
} }
}, },
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
"license": "ISC", "dev": true,
"dependencies": { "dependencies": {
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.0",
"signal-exit": "^4.0.1" "signal-exit": "^4.0.1"
}, },
"engines": { "engines": {
@ -10630,6 +10396,7 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"engines": { "engines": {
"node": ">=14" "node": ">=14"
}, },
@ -10953,6 +10720,7 @@
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"dependencies": { "dependencies": {
"is-glob": "^4.0.3" "is-glob": "^4.0.3"
}, },
@ -12226,7 +11994,8 @@
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
}, },
"node_modules/isomorphic-ws": { "node_modules/isomorphic-ws": {
"version": "5.0.0", "version": "5.0.0",
@ -16017,6 +15786,7 @@
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
@ -16285,6 +16055,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -16299,18 +16070,19 @@
} }
}, },
"node_modules/nostr-tools": { "node_modules/nostr-tools": {
"version": "2.16.2", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.16.2.tgz", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.8.0.tgz",
"integrity": "sha512-ZxH9EbSt5ypURZj2TGNJxZd0Omb5ag5KZSu8IyJMCdLyg2KKz+2GA0sP/cSawCQEkyviIN4eRT4G2gB/t9lMRw==", "integrity": "sha512-aumZBa9Ok/cAJLovSBCIA/DkJjLjF/Hs5DpQGEjmyfaUkGBqd5jZjzalcVMyy/9HkkRZfJmbTPtqHTKFNvBSHQ==",
"license": "Unlicense",
"dependencies": { "dependencies": {
"@noble/ciphers": "^0.5.1", "@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0", "@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1", "@noble/hashes": "1.3.1",
"@scure/base": "1.1.1", "@scure/base": "1.1.1",
"@scure/bip32": "1.3.1", "@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1", "@scure/bip39": "1.2.1"
"nostr-wasm": "0.1.0" },
"optionalDependencies": {
"nostr-wasm": "v0.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
@ -16346,7 +16118,8 @@
"node_modules/nostr-wasm": { "node_modules/nostr-wasm": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==" "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"optional": true
}, },
"node_modules/npm-run-path": { "node_modules/npm-run-path": {
"version": "4.0.1", "version": "4.0.1",
@ -16726,12 +16499,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/packet-reader": { "node_modules/packet-reader": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
@ -16827,6 +16594,7 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -18844,6 +18612,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"dependencies": { "dependencies": {
"shebang-regex": "^3.0.0" "shebang-regex": "^3.0.0"
}, },
@ -18855,6 +18624,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -19511,6 +19281,7 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0", "is-fullwidth-code-point": "^3.0.0",
@ -19630,6 +19401,7 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
}, },
@ -19905,34 +19677,6 @@
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
}, },
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tldts": { "node_modules/tldts": {
"version": "6.1.51", "version": "6.1.51",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.51.tgz", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.51.tgz",
@ -20461,9 +20205,9 @@
"integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==" "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g=="
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.2", "version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -21326,6 +21070,7 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"dependencies": { "dependencies": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
}, },
@ -21678,6 +21423,7 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
"string-width": "^4.1.0", "string-width": "^4.1.0",
@ -21694,6 +21440,7 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
}, },
@ -21708,6 +21455,7 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
}, },
@ -21718,7 +21466,8 @@
"node_modules/wrap-ansi-cjs/node_modules/color-name": { "node_modules/wrap-ansi-cjs/node_modules/color-name": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
}, },
"node_modules/wrap-ansi/node_modules/ansi-styles": { "node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",

View File

@ -25,7 +25,6 @@
"@nostr-dev-kit/ndk-wallet": "^0.5.0", "@nostr-dev-kit/ndk-wallet": "^0.5.0",
"@opensearch-project/opensearch": "^2.12.0", "@opensearch-project/opensearch": "^2.12.0",
"@prisma/client": "^5.20.0", "@prisma/client": "^5.20.0",
"@shocknet/clink-sdk": "^1.4.0",
"@slack/web-api": "^7.6.0", "@slack/web-api": "^7.6.0",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@yudiel/react-qr-scanner": "^2.0.8", "@yudiel/react-qr-scanner": "^2.0.8",
@ -41,7 +40,6 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clipboard-copy": "^4.0.1", "clipboard-copy": "^4.0.1",
"cookie": "^1.0.1", "cookie": "^1.0.1",
"copy-webpack-plugin": "^13.0.1",
"cross-fetch": "^4.0.0", "cross-fetch": "^4.0.0",
"csv-parser": "^3.0.0", "csv-parser": "^3.0.0",
"domino": "^2.1.6", "domino": "^2.1.6",

View File

@ -20,7 +20,6 @@ import { ChainFeeProvider } from '@/components/chain-fee.js'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { HasNewNotesProvider } from '@/components/use-has-new-notes' import { HasNewNotesProvider } from '@/components/use-has-new-notes'
import WalletsProvider from '@/wallets/client/context' import WalletsProvider from '@/wallets/client/context'
import FaviconProvider from '@/components/favicon'
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
@ -60,6 +59,14 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
router.events.on('routeChangeComplete', nprogressDone) router.events.on('routeChangeComplete', nprogressDone)
router.events.on('routeChangeError', nprogressDone) router.events.on('routeChangeError', nprogressDone)
const handleServiceWorkerMessage = (event) => {
if (event.data?.type === 'navigate') {
router.push(event.data.url)
}
}
navigator.serviceWorker?.addEventListener('message', handleServiceWorkerMessage)
if (!props?.apollo) return if (!props?.apollo) return
// HACK: 'cause there's no way to tell Next to skip SSR // HACK: 'cause there's no way to tell Next to skip SSR
// So every page load, we modify the route in browser history // So every page load, we modify the route in browser history
@ -82,6 +89,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
router.events.off('routeChangeStart', nprogressStart) router.events.off('routeChangeStart', nprogressStart)
router.events.off('routeChangeComplete', nprogressDone) router.events.off('routeChangeComplete', nprogressDone)
router.events.off('routeChangeError', nprogressDone) router.events.off('routeChangeError', nprogressDone)
navigator.serviceWorker?.removeEventListener('message', handleServiceWorkerMessage)
} }
}, [router.asPath, props?.apollo, shouldShowProgressBar]) }, [router.asPath, props?.apollo, shouldShowProgressBar])
@ -113,26 +121,24 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<MeProvider me={me}> <MeProvider me={me}>
<WalletsProvider> <WalletsProvider>
<HasNewNotesProvider> <HasNewNotesProvider>
<FaviconProvider> <ServiceWorkerProvider>
<ServiceWorkerProvider> <PriceProvider price={price}>
<PriceProvider price={price}> <AnimationProvider>
<AnimationProvider> <ToastProvider>
<ToastProvider> <ShowModalProvider>
<ShowModalProvider> <BlockHeightProvider blockHeight={blockHeight}>
<BlockHeightProvider blockHeight={blockHeight}> <ChainFeeProvider chainFee={chainFee}>
<ChainFeeProvider chainFee={chainFee}> <ErrorBoundary>
<ErrorBoundary> <Component ssrData={ssrData} {...otherProps} />
<Component ssrData={ssrData} {...otherProps} /> {!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />} </ErrorBoundary>
</ErrorBoundary> </ChainFeeProvider>
</ChainFeeProvider> </BlockHeightProvider>
</BlockHeightProvider> </ShowModalProvider>
</ShowModalProvider> </ToastProvider>
</ToastProvider> </AnimationProvider>
</AnimationProvider> </PriceProvider>
</PriceProvider> </ServiceWorkerProvider>
</ServiceWorkerProvider>
</FaviconProvider>
</HasNewNotesProvider> </HasNewNotesProvider>
</WalletsProvider> </WalletsProvider>
</MeProvider> </MeProvider>

View File

@ -327,43 +327,38 @@ export const getAuthOptions = (req, res) => ({
return user return user
}, },
useVerificationToken: async ({ identifier, token }) => { useVerificationToken: async ({ identifier, token }) => {
return await prisma.$transaction(async (tx) => { // we need to find the most recent verification request for this email/identifier
const [verificationRequest] = await tx.$queryRaw` const verificationRequest = await prisma.verificationToken.findFirst({
UPDATE verification_requests where: {
SET attempts = attempts + 1 identifier,
FROM ( attempts: {
SELECT id FROM verification_requests lt: 2 // count starts at 0
WHERE identifier = ${identifier} }
AND created_at > NOW() - INTERVAL '5 minutes' },
-- we need to find the most recent verification request for this email/identifier orderBy: {
ORDER BY created_at DESC createdAt: 'desc'
LIMIT 1
FOR UPDATE
) for_update
WHERE verification_requests.id = for_update.id
RETURNING *
`
if (!verificationRequest) throw new Error('No verification request found')
if (verificationRequest.token === token) {
// correct token was entered, delete the verification request because we no longer need it
await tx.verificationToken.delete({
where: { id: verificationRequest.id }
})
return verificationRequest
} }
if (verificationRequest.attempts >= 3) {
// too many attempts, delete the verification request and redirect to error page by throwing an error
await tx.verificationToken.delete({
where: { id: verificationRequest.id }
})
throw new Error('too many attempts')
}
// wrong code but can try again
return null
}) })
if (!verificationRequest) throw new Error('No verification request found')
if (verificationRequest.token === token) { // if correct delete the token and continue
await prisma.verificationToken.delete({
where: { id: verificationRequest.id }
})
return verificationRequest
}
await prisma.verificationToken.update({
where: { id: verificationRequest.id },
data: { attempts: { increment: 1 } }
})
await prisma.verificationToken.deleteMany({
where: { id: verificationRequest.id, attempts: { gte: 2 } }
})
return null
} }
}, },
session: { session: {
@ -413,9 +408,7 @@ function generateRandomString (length = 6, charset = BECH32_CHARSET) {
const bytes = randomBytes(length) const bytes = randomBytes(length)
let result = '' let result = ''
// Even though we're creating biased numbers by mapping each byte to a bech32 character, // Map each byte to a character in the charset
// this is still secure because it provides 30 bits of security (32^6 = 2^30)
// and we are limiting the number of attempts.
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
result += charset[bytes[i] % charset.length] result += charset[bytes[i] % charset.length]
} }

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