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

View File

@ -11,10 +11,10 @@ NEXT_PUBLIC_MEDIA_DOMAIN=m.stacker.news
PUBLIC_URL=https://stacker.news
SELF_URL=http://127.0.0.1:8080
grpc_proxy=http://127.0.0.1:7050
NEXT_PUBLIC_FAST_POLL_INTERVAL_MS=1000
NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS=30000
NEXT_PUBLIC_LONG_POLL_INTERVAL_MS=60000
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS=300000
NEXT_PUBLIC_FAST_POLL_INTERVAL=1000
NEXT_PUBLIC_NORMAL_POLL_INTERVAL=30000
NEXT_PUBLIC_LONG_POLL_INTERVAL=60000
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
NEXT_PUBLIC_URL=https://stacker.news
TOR_PROXY=http://127.0.0.1:7050/
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 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 ./
RUN npm ci --legacy-peer-deps --loglevel verbose
# 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"]
CMD ["sh","-c","npm install --loglevel verbose --legacy-peer-deps && npx prisma migrate dev && npm run dev"]

View File

@ -36,34 +36,6 @@ Go to [localhost:3000](http://localhost:3000).
<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
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
- [Getting started](#getting-started)
- [Installation](#installation)
- [GitHub Codespaces](#github-codespaces)
- [Usage](#usage)
- [Modifying services](#modifying-services)
- [Running specific services](#running-specific-services)

View File

@ -60,11 +60,17 @@ export async function estimateRouteFee ({ lnd, destination, tokens, mtokens, req
timeout
}, (err, res) => {
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) {
return reject(new Error(`Unable to estimate route: ${res.failure_reason}`))
if (res.routing_fee_msat < 0 || res.time_lock_delay <= 0) {
reject(new Error('Unable to estimate route, excessive values: ' + JSON.stringify(res)))
return
}
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[]))
+ ${satsToMsats(boost)}::INTEGER as cost`
// freebies currently only apply to bios (comments disabled). To keep togglable behavior,
// respect user's disableFreebies preference for bios as well.
// conditions: is a bio, cost <= baseCost, user logged in, insufficient balance & credits,
// and user has not disabled freebies
const freebie = bio && cost <= baseCost && !!me &&
me?.msats < cost && me?.mcredits < cost && !me?.disableFreebies
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon,
// cost must be greater than user's balance, and user has not disabled freebies
const freebie = (parentId || bio) && cost <= baseCost && !!me &&
me?.msats < cost && !me?.disableFreebies && me?.mcredits < cost
return freebie ? BigInt(0) : BigInt(cost)
}

View File

@ -1,5 +1,5 @@
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 { ruleSet as publicationDateRuleSet } from '@/lib/timedate-scraper'
import domino from 'domino'
@ -54,8 +54,7 @@ function commentsOrderByClause (me, models, sort) {
async function comments (me, models, item, sort, cursor) {
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 (!me && item.nDirectComments === 0) {
if (item.nDirectComments === 0) {
return {
comments: [],
cursor: null
@ -68,7 +67,7 @@ async function comments (me, models, item, sort, cursor) {
// XXX what a mess
let comments
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) {
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)',
@ -81,7 +80,7 @@ async function comments (me, models, item, sort, cursor) {
comments = fullComments
}
} 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) {
const [{ item_comments_limited: limitedComments }] = await models.$queryRawUnsafe(
'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".bio = false',
'"Item".boost > 0',
await filterClause(me, models),
activeOrMine(),
subClause(sub, 1, 'Item', me, showNsfw),
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",
"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)
|| jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub,
"CommentsViewAt"."last_viewed_at" as "meCommentsViewedAt"
|| jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub
FROM (
${query}
) "Item"
@ -194,7 +191,6 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
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 "CommentsViewAt" ON "CommentsViewAt"."itemId" = "Item".id AND "CommentsViewAt"."userId" = ${me.id}
LEFT JOIN LATERAL (
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",
@ -365,7 +361,7 @@ export default {
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)
let items, user, pins, subFull, table, ad
@ -586,7 +582,7 @@ export default {
break
}
return {
cursor: items.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
items,
pins,
ad
@ -744,7 +740,7 @@ export default {
subMaxBoost: subAgg?._max.boost || 0
}
},
newComments: async (parent, { itemId, after }, { models, me }) => {
newComments: async (parent, { rootId, after }, { models, me }) => {
const comments = await itemQueryWithMeta({
me,
models,
@ -758,7 +754,7 @@ export default {
'"Item"."created_at" > $2'
)}
ORDER BY "Item"."created_at" ASC`
}, Number(itemId), after)
}, Number(rootId), after)
return { comments }
}
@ -1076,21 +1072,6 @@ export default {
])
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: {
@ -1241,8 +1222,7 @@ export default {
return item.comments
}
// if we're logged in, there might be pending comments from us we want to show but weren't counted
if (!me && item.ncomments === 0) {
if (item.ncomments === 0) {
return {
comments: [],
cursor: null

View File

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

View File

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

View File

@ -6,7 +6,6 @@ import { notifyTerritoryTransfer } from '@/lib/webPush'
import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { uploadIdsFromText } from './upload'
import { Prisma } from '@prisma/client'
export async function getSub (parent, { name }, { models, me }) {
if (!name) return null
@ -37,15 +36,24 @@ export async function getSub (parent, { name }, { models, me }) {
export default {
Query: {
sub: getSub,
subSuggestions: async (parent, { q, limit }, { models }) => {
subSuggestions: async (parent, { q, limit = 5 }, { models }) => {
let subs = []
if (q) {
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status IN ('ACTIVE', 'GRACE')
${q ? Prisma.sql`AND SIMILARITY(name, ${q}) > 0.1` : Prisma.empty}
${q ? Prisma.sql`ORDER BY SIMILARITY(name, ${q}) DESC` : Prisma.sql`ORDER BY name ASC`}
WHERE status = 'ACTIVE'
AND SIMILARITY(name, ${q}) > 0.1
ORDER BY SIMILARITY(name, ${q}) DESC
LIMIT ${limit}`
} else {
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status = 'ACTIVE'
ORDER BY name ASC
LIMIT ${limit}`
}
return subs
},
@ -54,15 +62,14 @@ export default {
const currentUser = await models.user.findUnique({ where: { id: me.id } })
const showNsfw = currentUser ? currentUser.nsfwMode : false
return await models.$queryRaw`
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"
return await models.$queryRawUnsafe(`
SELECT "Sub".*, "Sub".created_at as "createdAt", COALESCE(json_agg("MuteSub".*) FILTER (WHERE "MuteSub"."userId" IS NOT NULL), '[]') AS "MuteSub"
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
WHERE status <> 'STOPPED' ${showNsfw ? Prisma.empty : Prisma.sql`AND "Sub"."nsfw" = FALSE`}
GROUP BY "Sub".name, ss."userId", "MuteSub"."userId"
WHERE status <> 'STOPPED' ${showNsfw ? '' : 'AND "Sub"."nsfw" = FALSE'}
GROUP BY "Sub".name, "MuteSub"."userId"
ORDER BY "Sub".name ASC
`
`)
}
return await models.sub.findMany({
@ -89,7 +96,7 @@ export default {
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 range = whenRange(when, from, to || decodeCursor.time)
@ -121,7 +128,7 @@ export default {
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) {
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 range = whenRange(when, from, to || decodeCursor.time)
@ -213,7 +213,7 @@ export default {
users
}
},
userSuggestions: async (parent, { q, limit }, { models }) => {
userSuggestions: async (parent, { q, limit = 5 }, { models }) => {
let users = []
if (q) {
users = await models.$queryRaw`
@ -287,7 +287,6 @@ export default {
'r.created_at > $2',
'r.created_at >= "ThreadSubscription".created_at',
'r."userId" <> $1',
'"Item"."deletedAt" IS NULL',
activeOrMine(me),
await filterClause(me, models),
muteClause(me),
@ -605,7 +604,7 @@ export default {
SELECT *
FROM users
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 }) => {
const range = whenRange(when, from, to)

View File

@ -1,18 +1,17 @@
import { gql } from 'graphql-tag'
import { LIMIT } from '@/lib/cursor'
export default gql`
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
pageTitleAndUnshorted(url: String!): TitleUnshorted
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
auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int!
newComments(itemId: ID, after: Date): Comments!
newComments(rootId: ID, after: Date): Comments!
}
type BoostPositions {
@ -65,7 +64,6 @@ export default gql`
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
pollVote(id: ID!): PollVotePaidAction!
toggleOutlaw(id: ID!): Item!
updateCommentsViewAt(id: ID!, meCommentsViewedAt: Date!): Date
}
type PollVoteResult {
@ -151,6 +149,7 @@ export default gql`
ncomments: Int!
nDirectComments: Int!
comments(sort: String, cursor: String): Comments!
injected: Boolean!
path: String
position: Int
prior: Int
@ -173,7 +172,6 @@ export default gql`
apiKey: Boolean
invoice: Invoice
cost: Int!
meCommentsViewedAt: Date
}
input ItemForwardInput {

View File

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

View File

@ -1,5 +1,4 @@
import { gql } from 'graphql-tag'
import { LIMIT } from '@/lib/cursor'
export default gql`
extend type Query {
@ -8,10 +7,10 @@ export default gql`
user(id: ID, name: String): User
users: [User!]
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!
searchUsers(q: String!, limit: Limit! = 5, similarity: Float): [User!]!
userSuggestions(q: String, limit: Limit! = 5): [User!]!
searchUsers(q: String!, limit: Limit, similarity: Float): [User!]!
userSuggestions(q: String, limit: Limit): [User!]!
hasNewNotes: Boolean!
mySubscribedUsers(cursor: String): Users!
myMutedUsers(cursor: String): Users!

View File

@ -55,12 +55,6 @@ const typeDefs = gql`
apiKey: VaultEntryInput!
): WalletSendBlink!
upsertWalletSendCLNRest(
${shared},
socket: String!,
rune: VaultEntryInput!,
): WalletSendCLNRest!
upsertWalletRecvBlink(
${shared},
currency: String!,
@ -108,11 +102,6 @@ const typeDefs = gql`
${shared}
): WalletSendWebLN!
upsertWalletRecvClink(
${shared},
noffer: String!
): WalletRecvClink!
# tests
testWalletRecvNWC(
url: String!
@ -149,12 +138,9 @@ const typeDefs = gql`
apiKey: String!
): Boolean!
testWalletRecvClink(
noffer: String!
): Boolean!
# delete
deleteWallet(id: ID!): Boolean
removeWallet(id: ID!): Boolean
removeWalletProtocol(id: ID!): Boolean
# crypto
updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean
@ -163,7 +149,7 @@ const typeDefs = gql`
disablePassphraseExport: Boolean
# settings
setWalletSettings(settings: WalletSettingsInput!): WalletSettings!
setWalletSettings(settings: WalletSettingsInput!): Boolean
setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean
# logs
@ -227,7 +213,6 @@ const typeDefs = gql`
| WalletSendBlink
| WalletSendWebLN
| WalletSendLNC
| WalletSendCLNRest
| WalletRecvNWC
| WalletRecvLNbits
| WalletRecvPhoenixd
@ -235,7 +220,6 @@ const typeDefs = gql`
| WalletRecvLightningAddress
| WalletRecvCLNRest
| WalletRecvLNDGRPC
| WalletRecvClink
type WalletSettings {
receiveCreditsBelowSats: Int!
@ -290,12 +274,6 @@ const typeDefs = gql`
serverHost: VaultEntry!
}
type WalletSendCLNRest {
id: ID!
socket: String!
rune: VaultEntry!
}
type WalletRecvNWC {
id: ID!
url: String!
@ -338,11 +316,6 @@ const typeDefs = gql`
cert: String
}
type WalletRecvClink {
id: ID!
noffer: String!
}
input AutowithdrawSettings {
autoWithdrawThreshold: Int!
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
ed-kung,pr,#2373,#2371,good-first-issue,,,,20k,simplestacker@getalby.com,2025-07-31
ed-kung,issue,#2373,#2371,good-first-issue,,,,2k,simplestacker@getalby.com,2025-07-31
pory-gone,pr,#2381,#2370,good-first-issue,,,,20k,pory@porygone.xyz,2025-08-26
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,???
pory-gone,pr,#2381,#2370,good-first-issue,,,,20k,pory@porygone.xyz,???
pory-gone,pr,#2413,#2361,easy,,,,100k,pory@porygone.xyz,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
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 () {
return (
<ol>
<ol style={{ lineHeight: 1.25 }}>
<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 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>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>If a boost is outlawed, it will only be visible to stackers in wild west mode</li>
</ol>
)
}

View File

@ -5,12 +5,11 @@ import CowboyHatIcon from '@/svgs/cowboy.svg'
import AnonIcon from '@/svgs/spy-fill.svg'
import GunIcon from '@/svgs/revolver.svg'
import HorseIcon from '@/svgs/horse.svg'
import BotIcon from '@/svgs/robot-2-fill.svg'
import { numWithUnits } from '@/lib/format'
import { USER_ID } from '@/lib/constants'
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 (Number(user.id) === USER_ID.anon) {
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
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
return (

View File

@ -1,6 +1,6 @@
import { createContext, useContext, useMemo } from 'react'
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 { datePivot } from '@/lib/time'
@ -18,7 +18,7 @@ export const BlockHeightProvider = ({ blockHeight, children }) => {
...(SSR
? {}
: {
pollInterval: NORMAL_POLL_INTERVAL_MS,
pollInterval: NORMAL_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network'
})
})

View File

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

View File

@ -1,6 +1,6 @@
import { createContext, useContext, useMemo } from 'react'
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'
export const ChainFeeContext = createContext({
@ -14,7 +14,7 @@ export const ChainFeeProvider = ({ chainFee, children }) => {
...(SSR
? {}
: {
pollInterval: NORMAL_POLL_INTERVAL_MS,
pollInterval: NORMAL_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network'
})
})

View File

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

View File

@ -18,7 +18,9 @@
.dontLike {
fill: #a5a5a5;
margin-right: 2px;
margin-right: .35rem;
padding: 2px;
margin-left: 1px;
margin-top: 9px;
cursor: pointer;
}
@ -62,23 +64,19 @@
.children {
margin-top: 0;
margin-left: 15px;
margin-left: 27px;
}
.comments {
margin-left: -15px;
margin-left: -.75rem;
}
@media screen and (min-width: 768px) {
.comments {
margin-left: 0px;
margin-left: .75rem;
}
.children {
margin-left: 25px;
}
}
.skeleton .hunk {
width: 100%;
@ -139,30 +137,34 @@
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;
}
@keyframes fadeIn {
from { opacity: 0; }
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 { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
import useLiveComments from './use-live-comments'
import { useCommentsNavigatorContext } from './use-comments-navigator'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter()
@ -71,10 +70,7 @@ export default function Comments ({
const router = useRouter()
// fetch new comments that arrived after the lastCommentAt, and update the item.comments field in cache
useLiveComments(parentId, lastCommentAt || parentCreatedAt)
// new comments navigator, tracks new comments and provides navigation controls
const { navigator } = useCommentsNavigatorContext()
useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort)
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
@ -98,11 +94,11 @@ export default function Comments ({
: null}
{pins.map(item => (
<Fragment key={item.id}>
<Comment depth={1} item={item} navigator={navigator} {...props} pin />
<Comment depth={1} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin />
</Fragment>
))}
{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 &&
<MoreFooter

View File

@ -7,7 +7,6 @@ import Flag from '@/svgs/flag-fill.svg'
import { useMemo } from 'react'
import getColor from '@/lib/rainbow'
import { gql, useMutation } from '@apollo/client'
import styles from './upvote.module.css'
export function DownZap ({ item, ...props }) {
const { meDontLikeSats } = item
@ -21,9 +20,7 @@ export function DownZap ({ item, ...props }) {
<DownZapper
item={item} As={({ ...oprops }) =>
<div className='upvoteParent'>
<div className={styles.upvoteWrapper}>
<Flag {...props} {...oprops} style={style} />
</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 styles from './fee-button.module.css'
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 { useMe } from './me'
import AnonIcon from '@/svgs/spy-fill.svg'
@ -14,7 +14,7 @@ import { SubmitButton } from './form'
const FeeButtonContext = createContext()
export function postCommentBaseLineItems ({ baseCost = 1, comment = false, bio = false, me }) {
export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me }) {
const anonCharge = me
? {}
: {
@ -28,10 +28,10 @@ export function postCommentBaseLineItems ({ baseCost = 1, comment = false, bio =
return {
baseCost: {
term: baseCost,
label: `${bio ? 'bio' : (comment ? 'comment' : 'post')} cost`,
label: `${comment ? 'comment' : 'post'} cost`,
op: '_',
modifier: (cost) => cost + baseCost,
allowFreebies: bio
allowFreebies: comment
},
...anonCharge
}
@ -45,7 +45,7 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
return function useRemoteLineItems () {
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(() => {
const repetition = data?.itemRepetition

View File

@ -1,7 +1,7 @@
import { gql, useQuery } from '@apollo/client'
import Link from 'next/link'
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`
{
@ -12,7 +12,7 @@ const REWARDS = gql`
}`
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 time = data?.rewards?.[0]?.time
return (

View File

@ -12,13 +12,10 @@ import No from '@/svgs/no.svg'
import Bolt from '@/svgs/bolt.svg'
import Amboss from '@/svgs/amboss.svg'
import Mempool from '@/svgs/bimi.svg'
import Live from '@/svgs/chat-unread-fill.svg'
import NoLive from '@/svgs/chat-off-fill.svg'
import Rewards from './footer-rewards'
import useDarkMode from './dark-mode'
import ActionTooltip from './action-tooltip'
import { useAnimationEnabled } from '@/components/animation'
import { useLiveCommentsToggle } from './use-live-comments'
const RssPopover = (
<Popover>
@ -100,6 +97,13 @@ const SocialsPopover = (
const ChatPopover = (
<Popover>
<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
href='https://t.me/k00bideh' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
@ -108,10 +112,10 @@ const ChatPopover = (
</a>
<span className='mx-2 text-muted'> \ </span>
<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'
>
signal
simplex
</a>
</Popover.Body>
</Popover>
@ -143,11 +147,8 @@ export default function Footer ({ links = true }) {
const [animationEnabled, toggleAnimation] = useAnimationEnabled()
const [disableLiveComments, toggleLiveComments] = useLiveCommentsToggle()
const DarkModeIcon = darkMode ? Sun : Moon
const LnIcon = animationEnabled ? No : Bolt
const LiveIcon = disableLiveComments ? Live : NoLive
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`}>
<LnIcon onClick={toggleAnimation} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
</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 className='mb-0' style={{ fontWeight: 500 }}>
<Rewards />
@ -251,9 +249,6 @@ export default function Footer ({ links = true }) {
<Link href='/ek' className='ms-1'>
@ek
</Link>
<Link href='/sox' className='ms-1'>
@sox
</Link>
<span className='ms-1'>&</span>
<Link href='https://github.com/stackernews/stacker.news/graphs/contributors' className='ms-1' target='_blank' rel='noreferrer'>
more

View File

@ -1451,7 +1451,6 @@ export function MultiInput ({
onChange, autoFocus, hideError, inputType = 'text',
...props
}) {
const formik = useFormikContext()
const [inputs, setInputs] = useState(new Array(length).fill(''))
const inputRefs = useRef(new Array(length).fill(null))
const [, meta, helpers] = useField({ name })
@ -1550,7 +1549,7 @@ export function MultiInput ({
))}
</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'>
{meta.error}
</BootstrapForm.Control.Feedback>

View File

@ -5,7 +5,7 @@ export default function CCInfo (props) {
return (
<Info {...props}>
<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>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>

View File

@ -5,7 +5,7 @@ export default function RewardSatsInfo (props) {
return (
<Info {...props}>
<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>sats also come from <Link href='/rewards'>daily rewards</Link> and territory revenue
<ul>

View File

@ -7,7 +7,7 @@ import PayerData from './payer-data'
import Bolt11Info from './bolt11-info'
import { useQuery } from '@apollo/client'
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 ItemJob from './item-job'
import Item from './item'
@ -24,7 +24,7 @@ export default function Invoice ({
const { data, error } = useQuery(query, SSR
? {}
: {
pollInterval: FAST_POLL_INTERVAL_MS,
pollInterval: FAST_POLL_INTERVAL,
variables: { id },
nextFetchPolicy: 'cache-and-network',
skip: !poll

View File

@ -11,6 +11,7 @@ import { useMe } from './me'
import Button from 'react-bootstrap/Button'
import { useEffect } from 'react'
import Poll from './poll'
import { commentsViewed } from '@/lib/new-comments'
import Related from './related'
import PastBounties from './past-bounties'
import Check from '@/svgs/check-double-line.svg'
@ -25,7 +26,6 @@ import { UNKNOWN_LINK_REL } from '@/lib/constants'
import classNames from 'classnames'
import { CarouselProvider } from './carousel'
import Embed from './embed'
import useCommentsView from './use-comments-view'
function BioItem ({ item, handleClick }) {
const { me } = useMe()
@ -161,12 +161,9 @@ function ItemText ({ item }) {
}
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(() => {
markItemViewed(item)
}, [item.id, markItemViewed])
commentsViewed(item)
}, [item.lastCommentAt])
return (
<>

View File

@ -134,7 +134,7 @@ export default function ItemInfo ({
{showUser &&
<Link href={`/${item.user.name}`}>
<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}
</Link>}
<span> </span>
@ -166,6 +166,9 @@ export default function ItemInfo ({
{' '}<Badge className={styles.newComment} bg={null}>freebie</Badge>
</Link>
)}
{(item.apiKey &&
<>{' '}<Badge className={styles.newComment} bg={null}>bot</Badge></>
)}
{extraBadges}
{
showActionDropdown &&

View File

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

View File

@ -114,7 +114,7 @@ export default function JobForm ({ item, sub }) {
label={
<div className='d-flex align-items-center'>boost
<Info>
<ol>
<ol className='line-height-md'>
<li>Boost ranks jobs higher based on the amount</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { 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 BackIcon from '@/svgs/arrow-left-line.svg'
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 }) {
const query = gql`
@ -19,7 +19,7 @@ function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
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(() => {
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 router = useRouter()
multiAuth = typeof multiAuth === 'string' ? multiAuth === 'true' : !!multiAuth
// signup/signin awareness cookie
useEffect(() => {
// expire cookie if we're on /signup instead of /login

View File

@ -1,14 +1,14 @@
import React, { useContext } from 'react'
import { useQuery } from '@apollo/client'
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({
me: null
})
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.
// without this, we would always fallback to the `me` object
// 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 }) {
const media = useMediaHelper(props)
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(() => {
if (!media.image) return
confirmMedia(media.bestResSrc)
}, [confirmMedia, media.image, media.bestResSrc])
addMedia({ src: media.bestResSrc, originalSrc: media.originalSrc, rel: props.rel })
}, [media.image])
const handleClick = useCallback(() => showCarousel({ src: 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
setIsImage(true)
}).catch((e) => {
console.warn('Cannot decode image:', src, e)
console.error('Cannot decode image', e)
})
}
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 SnIcon from '@/svgs/sn.svg'
import { useHasNewNotes } from '../use-has-new-notes'
// import { useWallets } from '@/wallets/client/hooks'
import { useWalletIndicator } from '@/wallets/client/hooks'
import SwitchAccountList, { nextAccount, useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal'
import { numWithUnits } from '@/lib/format'
import Head from 'next/head'
export function Brand ({ className }) {
return (
@ -119,6 +121,9 @@ export function NavNotifications ({ className }) {
return (
<>
<Head>
<link rel='shortcut icon' href={hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
</Head>
<Link href='/notifications' passHref legacyBehavior>
<Nav.Link eventKey='notifications' className={classNames('position-relative', className)}>
<NoteIcon height={28} width={20} className='theme' />
@ -159,11 +164,9 @@ export function NavWalletSummary ({ className }) {
)
}
export const Indicator = ({ show, children }) => {
export const Indicator = ({ superscript }) => {
if (superscript) {
return (
<div className='w-fit-content position-relative'>
{children}
{show && (
<span className='d-inline-block p-1'>
<span
className='position-absolute p-1 bg-secondary'
@ -172,7 +175,12 @@ export const Indicator = ({ show, children }) => {
<span className='invisible'>{' '}</span>
</span>
</span>
)}
)
}
return (
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
</div>
)
}
@ -190,7 +198,8 @@ export function MeDropdown ({ me, dropNavKey }) {
<Dropdown.Toggle className='nav-link nav-item fw-normal' id='profile' variant='custom'>
<div className='d-flex align-items-center'>
<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>
<Badges user={me} />
</div>
@ -198,7 +207,8 @@ export function MeDropdown ({ me, dropNavKey }) {
<Dropdown.Menu>
<Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}>
<Indicator show={profileIndicator}>profile</Indicator>
profile
{profileIndicator && <Indicator />}
</Dropdown.Item>
</Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
@ -206,7 +216,8 @@ export function MeDropdown ({ me, dropNavKey }) {
</Link>
<Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'>
<Indicator show={walletIndicator}>wallets</Indicator>
wallets
{walletIndicator && <Indicator />}
</Dropdown.Item>
</Link>
<Link href='/credits' passHref legacyBehavior>
@ -282,6 +293,7 @@ export default function LoginButton () {
function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
// const { removeLocalWallets } = useWallets()
const router = useRouter()
return (

View File

@ -2,11 +2,9 @@ import { Nav, Navbar } from 'react-bootstrap'
import styles from '../../header.module.css'
import { AnonCorner, Back, Brand, MeCorner, NavPrice, SearchItem } from '../common'
import { useMe } from '../../me'
import { useCommentsNavigatorContext, CommentsNavigator } from '@/components/use-comments-navigator'
export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const { me } = useMe()
const { navigator, commentCount } = useCommentsNavigatorContext()
return (
<Navbar>
<Nav
@ -17,7 +15,6 @@ export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
<Brand className='me-1' />
<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' />
<CommentsNavigator navigator={navigator} commentCount={commentCount} />
{me
? <MeCorner dropNavKey={dropNavKey} me={me} 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 walletIndicator = useWalletIndicator()
const indicator = profileIndicator || walletIndicator
return (
<>
<Indicator show={indicator}><MeImage onClick={handleShow} /></Indicator>
<MeImage onClick={handleShow} />
<Offcanvas className={canvasStyles.offcanvas} show={show} onHide={handleClose} placement='end'>
<Offcanvas.Header closeButton>
<Offcanvas.Title><NavWalletSummary /></Offcanvas.Title>
@ -53,7 +53,8 @@ export default function OffCanvas ({ me, dropNavKey }) {
<>
<Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}>
<Indicator show={profileIndicator}>profile</Indicator>
profile
{profileIndicator && <Indicator />}
</Dropdown.Item>
</Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
@ -61,7 +62,8 @@ export default function OffCanvas ({ me, dropNavKey }) {
</Link>
<Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'>
<Indicator show={walletIndicator}>wallets</Indicator>
wallets
{walletIndicator && <Indicator />}
</Dropdown.Item>
</Link>
<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'>
<MeImage />
<div className='ms-2'>
<Indicator show={indicator}>@{me?.name || 'anon'}</Indicator>
@{me?.name || 'anon'}
</div>
</Link>
</Nav>

View File

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

View File

@ -4,7 +4,7 @@ import { fixedDecimal } from '@/lib/format'
import { useMe } from './me'
import { PRICE } from '@/fragments/price'
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 { useChainFee } from './chain-fee'
import { CompactLongCountdown } from './countdown'
@ -27,7 +27,7 @@ export function PriceProvider ({ price, children }) {
...(SSR
? {}
: {
pollInterval: NORMAL_POLL_INTERVAL_MS,
pollInterval: NORMAL_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network'
})
})

View File

@ -17,13 +17,6 @@ export default function PullToRefresh ({ children, className }) {
setIsPWA(androidPWA || iosPWA)
}
const clearPullDistance = () => {
setPullDistance(0)
document.body.style.marginTop = '0px'
touchStartY.current = 0
touchEndY.current = 0
}
useEffect(checkPWA, [])
const handleTouchStart = useCallback((e) => {
@ -35,13 +28,6 @@ export default function PullToRefresh ({ children, className }) {
const handleTouchMove = useCallback((e) => {
if (touchStartY.current === 0) 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
const distance = touchEndY.current - touchStartY.current
setPullDistance(distance)
@ -53,7 +39,10 @@ export default function PullToRefresh ({ children, className }) {
if (touchEndY.current - touchStartY.current > REFRESH_THRESHOLD) {
router.push(router.asPath)
}
clearPullDistance()
setPullDistance(0)
document.body.style.marginTop = '0px'
touchStartY.current = 0
touchEndY.current = 0
}, [router])
useEffect(() => {

View File

@ -1,18 +1,19 @@
import { Form, MarkdownInput } from '@/components/form'
import styles from './reply.module.css'
import { COMMENTS } from '@/fragments/comments'
import { useMe } from './me'
import { forwardRef, useCallback, useEffect, useState, useRef, useMemo } from 'react'
import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
import { commentsViewedAfterComment } from '@/lib/new-comments'
import { commentSchema } from '@/lib/validate'
import { ItemButtonBar } from './post'
import { useShowModal } from './modal'
import { Button } from 'react-bootstrap'
import { useRoot } from './root'
import { CREATE_COMMENT } from '@/fragments/paidAction'
import { injectComment } from '@/lib/comments'
import useItemSubmit from './use-item-submit'
import gql from 'graphql-tag'
import useCommentsView from './use-comments-view'
import { updateAncestorsCommentCount } from '@/lib/comments'
export default forwardRef(function Reply ({
item,
@ -29,7 +30,6 @@ export default forwardRef(function Reply ({
const showModal = useShowModal()
const root = useRoot()
const sub = item?.sub || root?.sub
const { markCommentViewedAt } = useCommentsView(root.id)
useEffect(() => {
if (replyOpen || quote || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) {
@ -51,11 +51,23 @@ export default forwardRef(function Reply ({
update (cache, { data: { upsertComment: { result, invoice } } }) {
if (!result) return
// inject the new comment into the cache
const injected = injectComment(cache, result)
if (injected) {
markCommentViewedAt(result.createdAt, { ncomments: 1 })
cache.modify({
id: `Item:${parentId}`,
fields: {
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
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 }) => {

View File

@ -2,7 +2,6 @@
max-width: 600px;
padding-right: 15px;
padding-bottom: 1rem;
margin-left: 12px;
}
.replyButtons {
@ -13,7 +12,6 @@
align-items: center;
line-height: 1rem;
vertical-align: middle;
margin-left: 12px;
}
.replyButtons > * {
@ -50,13 +48,3 @@
margin-top: -1px;
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 { useMe } from './me'
import { useField } from 'formik'
import { searchSchema } from '@/lib/validate'
export default function Search ({ sub }) {
const router = useRouter()
@ -66,7 +65,6 @@ export default function Search ({ sub }) {
<Form
initial={{ q, what, sort, when, from: '', to: '' }}
onSubmit={values => search({ ...values })}
schema={searchSchema}
>
<div className={`${styles.active} mb-3`}>
<SearchInput

View File

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

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
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 { useQuery } from '@apollo/client'
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
? {}
: {
pollInterval: EXTRA_LONG_POLL_INTERVAL_MS,
pollInterval: EXTRA_LONG_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network'
})

View File

@ -2,8 +2,11 @@ import React, { useMemo, useState } from 'react'
import Dropdown from 'react-bootstrap/Dropdown'
import FormControl from 'react-bootstrap/FormControl'
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 { extractHeadings } from '@/lib/toc'
export default function Toc ({ text }) {
const router = useRouter()
@ -11,7 +14,16 @@ export default function Toc ({ text }) {
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) {
return null

View File

@ -20,7 +20,6 @@ import rehypeSN from '@/lib/rehype-sn'
import remarkUnicode from '@/lib/remark-unicode'
import Embed from './embed'
import remarkMath from 'remark-math'
import remarkToc from '@/lib/remark-toc'
const rehypeSNStyled = () => rehypeSN({
stylers: [{
@ -34,11 +33,7 @@ const rehypeSNStyled = () => rehypeSN({
}]
})
const baseRemarkPlugins = [
gfm,
remarkUnicode,
[remarkMath, { singleDollarTextMath: false }]
]
const remarkPlugins = [gfm, remarkUnicode, [remarkMath, { singleDollarTextMath: false }]]
export function SearchText ({ text }) {
return (
@ -54,9 +49,6 @@ export function SearchText ({ text }) {
// this is one of the slowest components to render
export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) {
// include remarkToc if topLevel
const remarkPlugins = topLevel ? [...baseRemarkPlugins, remarkToc] : baseRemarkPlugins
// would the text overflow on the current screen size?
const [overflowing, setOverflowing] = useState(false)
// should we show the full text?
@ -243,9 +235,9 @@ function MediaLink ({
function Table ({ node, ...props }) {
return (
<div className='table-responsive'>
<span className='table-responsive'>
<table className='table table-bordered table-sm' {...props} />
</div>
</span>
)
}

View File

@ -241,7 +241,6 @@
.text table {
width: auto;
white-space: nowrap;
}
.text blockquote {

View File

@ -20,8 +20,7 @@ export default function useCanEdit (item) {
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
setCanEdit(canEdit => canEdit || anonEdit)
// update when the hmac gets set
}, [item?.invoice?.hmac])
}, [])
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 { NORMAL_POLL_INTERVAL_MS, SSR } from '@/lib/constants'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { useQuery } from '@apollo/client'
import React, { useContext } from 'react'
import { clearNotifications } from '@/components/serviceworker'
@ -11,7 +11,7 @@ export function HasNewNotesProvider ({ me, children }) {
SSR
? {}
: {
pollInterval: NORMAL_POLL_INTERVAL_MS,
pollInterval: NORMAL_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network',
onCompleted: ({ 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 { GET_NEW_COMMENTS } from '../fragments/comments'
import { injectComment } from '../lib/comments'
import useCommentsView from './use-comments-view'
import { useEffect, useState } from 'react'
import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { useQuery, useApolloClient } from '@apollo/client'
import { commentsViewedAfterComment } from '../lib/new-comments'
import {
updateItemQuery,
updateCommentFragment,
getLatestCommentCreatedAt,
updateAncestorsCommentCount,
calculateDepth
} from '../lib/comments'
// live comments polling interval
const POLL_INTERVAL = 1000 * 5
// live comments toggle keys
const STORAGE_DISABLE_KEY = 'disableLiveComments'
const TOGGLE_EVENT = 'liveComments:toggle'
const POLL_INTERVAL = 1000 * 5 // 5 seconds
const readStoredLatest = (key, latest) => {
const stored = window.sessionStorage.getItem(key)
return stored && stored > latest ? stored : latest
}
// prepares and creates a fragment for injection into the cache
// also handles side effects like updating comment counts and viewedAt timestamps
function prepareComments (item, cache, newComment) {
const existingComments = item.comments?.comments || []
// cache new comments and return the most recent timestamp between current latest and new comment
// regardless of whether the comments were injected or not
function cacheNewComments (cache, latest, itemId, newComments, markCommentViewedAt) {
let injected = 0
// 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
const injectedLatest = newComments.reduce((latestTimestamp, newComment) => {
const result = injectComment(cache, newComment, { live: true, rootId: itemId })
// if any comment was injected, increment injected
injected = result ? injected + 1 : injected
return new Date(newComment.createdAt) > new Date(latestTimestamp)
? newComment.createdAt
: latestTimestamp
}, latest)
// count the new comment (+1) and its children (+ncomments)
const totalNComments = newComment.ncomments + 1
if (injected > 0) {
markCommentViewedAt(injectedLatest, { ncomments: injected })
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 injectedLatest
return payload
}
// fetches comments for an item that are newer than the latest comment createdAt (after),
// injects them into cache, and keeps scroll position stable.
export default function useLiveComments (itemId, after) {
const latestKey = `liveCommentsLatest:${itemId}`
const { cache } = useApolloClient()
const { markCommentViewedAt } = useCommentsView(itemId)
const [disableLiveComments] = useLiveCommentsToggle()
function cacheNewComments (cache, rootId, newComments, sort) {
for (const newComment of newComments) {
const { parentId } = newComment
const topLevel = Number(parentId) === Number(rootId)
// if the comment is a top level comment, update the item, else update the parent comment
if (topLevel) {
updateItemQuery(cache, rootId, sort, (item) => prepareComments(item, cache, newComment))
} else {
// if the comment is too deep, we can skip it
const depth = calculateDepth(newComment.path, rootId, parentId)
if (depth > COMMENT_DEPTH_LIMIT) continue
// inject the new comment into the parent comment's comments field
updateCommentFragment(cache, parentId, (parent) => prepareComments(parent, cache, newComment))
}
}
}
// useLiveComments fetches new comments under an item (rootId),
// that are newer than the latest comment createdAt (after), and injects them into the cache.
export default function useLiveComments (rootId, after, sort) {
const latestKey = `liveCommentsLatest:${rootId}`
const { cache } = useApolloClient()
const [latest, setLatest] = useState(after)
const [initialized, setInitialized] = useState(false)
useEffect(() => {
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
// this prevents GET_NEW_COMMENTS from producing results before the page has fully rendered
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 },
nextFetchPolicy: 'cache-and-network',
skip: SSR || !initialized || disableLiveComments
variables: { rootId, after: latest },
nextFetchPolicy: 'cache-and-network'
})
useEffect(() => {
const newComments = data?.newComments?.comments
if (!newComments?.length) return
if (!data?.newComments?.comments?.length) return
// directly inject new comments into the cache, preserving scroll position
// quirk: scroll is preserved even if we are not injecting new comments due to dedupe
const injectedLatest = preserveScroll(() => cacheNewComments(cache, latest, itemId, newComments, markCommentViewedAt))
// if we didn't process any newer comments, bail
if (new Date(injectedLatest).getTime() <= new Date(latest).getTime()) return
preserveScroll(() => cacheNewComments(cache, rootId, data.newComments.comments, sort))
// update latest timestamp to the latest comment created at
// save it to session storage, to persist between client-side navigations
setLatest(injectedLatest)
window.sessionStorage.setItem(latestKey, injectedLatest)
}, [data, cache, itemId, latest, markCommentViewedAt])
}
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)
const newLatest = getLatestCommentCreatedAt(data.newComments.comments, latest)
setLatest(newLatest)
if (typeof window !== 'undefined') {
window.sessionStorage.setItem(latestKey, newLatest)
}
}, [])
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]
}, [data, cache, rootId, sort, latest])
}

View File

@ -10,7 +10,8 @@ import { useWalletPayment } from '@/wallets/client/hooks'
this is just like useMutation with a few changes:
1. pays an invoice returned by the mutation
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
of completion can still rely on it
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
const {
onPaid, onPayError, persistOnNavigate,
onPaid, onPayError, forceWaitForPayment, persistOnNavigate,
update, waitFor = inv => inv?.actionState === 'PAID', updateOnFallback
} = { ...options, ...innerOptions }
const ourOnCompleted = innerOnCompleted || onCompleted
@ -106,7 +107,7 @@ export function usePaidMutation (mutation,
})
// 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
ourOnCompleted?.(data)
// don't wait to pay the invoice
@ -206,8 +207,7 @@ export const paidActionCacheMods = {
fields: {
actionState: () => 'PAID',
confirmedAt: () => new Date().toISOString(),
satsReceived: () => invoice.satsRequested,
hmac: () => invoice.hmac
satsReceived: () => invoice.satsRequested
}
})
}

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
klk
brymut
abhishandy

View File

@ -530,9 +530,8 @@ services:
- '--bitcoin-rpcuser=${RPC_USER}'
- '--bitcoin-rpcpassword=${RPC_PASS}'
- '--large-channels'
- '--clnrest-port=3010'
- '--clnrest-host=0.0.0.0'
- '--clnrest-protocol=http'
- '--rest-port=3010'
- '--rest-host=0.0.0.0'
expose:
- "9735"
ports:
@ -843,27 +842,6 @@ services:
CONNECT: "localhost:${LNBITS_WEB_PORT_V1}"
TORDIR: "/app/.tor"
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:
image: 4km3/dnsmasq:2.90-r3
profiles:
@ -900,7 +878,6 @@ volumes:
tordata:
eclair:
dnsmasq:
lnpub:
networks:
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
# https://docs.corelightning.org/docs/grpc#generating-custom-certificates-optional
RUN apt-get update -y \
&& 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/"]

View File

@ -1 +1 @@
70A2D30FE991B24B5A6BF85421BE5EF083665E84
70A2D30FE991B24B5A6BF85421BE5EF083665E81

View File

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

View File

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

View File

@ -1,15 +1,14 @@
-----BEGIN CERTIFICATE-----
MIICYDCCAgagAwIBAgIUcKLTD+mRsktaa/hUIb5e8INmXoQwCgYIKoZIzj0EAwIw
FjEUMBIGA1UEAwwLY2xuIFJvb3QgQ0EwHhcNMjUwOTAxMjMzOTE5WhcNMjYwOTAx
MjMzOTE5WjAaMRgwFgYDVQQDDA9jbG4gcmVzdCBzZXJ2ZXIwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQC7LKm+zhlA/q9KtlmjLsLCSSFla0SsJmde1BQf
nIZvjl093Rt1pVpRCENWjYfDub6f1ceLmWpi9fCFPRPwRUpE/NATIWJlSO7/DHBs
XRVMQiM7E2hRe16/z3OK7QV93cIPnNMq+JCeKYbyI8qh+bLV61w611iCGaf2EjKm
tHazKES3qGeO4/8IeLAv+Y7zsSfsErz8J8lOuM+MpMIl7ulSEK4DILJ2lc/f0/Co
m5JVjpe/tm4u+GuM67NKDt6WtNA5qTn+JZDWSOJ3rtZ/+FfleAf5VH4eJ/TZKSeA
0PlzSl06Suvu+R+MKT0llHl9yDfk7Afg/prABykC3OoeNLxBAgMBAAGjYzBhMB8G
A1UdEQQYMBaHBH8AAAGCCWxvY2FsaG9zdIIDY2xuMB0GA1UdDgQWBBS2TbrQ7FiH
i01mjkx67gh+nBwX8zAfBgNVHSMEGDAWgBSEcmN/9rzS2hR6G7EIgsX+51N0CjAK
BggqhkjOPQQDAgNIADBFAiAA2ffEzwtVk5VnHbO00VWaEM3G08M8T0WwUmT1Vvy3
IAIhALOJl3d4R/PXiGcw8vRse8iRi+TmN3ZXB0QEfubSsfBP
MIICLzCCAdUCFHCi0w/pkbJLWmv4VCG+XvCDZl6BMAoGCCqGSM49BAMCMBYxFDAS
BgNVBAMMC2NsbiBSb290IENBMB4XDTI0MTEyMDE4MDgxMVoXDTI1MTEyMDE4MDgx
MVowUzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEMMAoGA1UEAwwDY2xuMIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyTNzl/YXp8II2bwRBppwxA7huV1xAM2E
73dyc/HqHsZE1510QCXZ4JtIFbBQRcGxjq5+KyR9TXOVgMROmfmSFuA470IvIYT0
B2aVnrLkawVwnRft3VFnQmBxkgP3Pp3XEMe1mZGaQqTmk1/AdjIX3YH7qBIbhoC/
KAmrr/+eaA7G0GZYgrYtkqVw7e2sznpzO1oiW8zFnpmiWs91mzrIdkGRC311ktSp
qTdYwZjeyJnGzVeyF//MytJmdmLzkAWOR9OpvKt5V7UhWLTLGULsGAzYaSl8PXun
stRiVjWeLPFIVBtzni0Lry+6PF1ltqAPS6ZSRd3bMtdHEjG/76ztyQIDAQABMAoG
CCqGSM49BAMCA0gAMEUCIDExbaYoitsXHdu8wgVlv7LozbNK9Te6t292ctH0dZCy
AiEAjg/GqpNIv01ACeK/5+HVLtba8TL6vBd9dENR4ADDoj4=
-----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-----
MIICkTCCAXkCAQAwGjEYMBYGA1UEAwwPY2xuIHJlc3Qgc2VydmVyMIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt+KCYNwj+YJNQXlJwuFiMvJK5nU1DOcp
9dvo4gvBClzwOeaRPsB4qnkWLxZqQ8+/q+LMD7P+QUXHqI49b8SFm7FFEpCL5GEN
090F1+GIEUBgEqMPHP3QRfyhnI4hmo0fgGArI7BMhBZrYH7nRKSNoEgpWd8dtm9N
R/jx7j9sWXtFx6LZoM6yZZXVy6DdDGNk7WR0o98fR3NtT+OzUAkCMJR45aT7SYUN
adCVNcFKsJqVMq0HDoa4sW1u184Fg5Bx38+gDXGn4VsDXwVXNMNxmGNY7ADn+AiR
q0MLGDu6B5a+Raqt+LAlvvsqbFww8wtWUHQ2gnvXZBEVS+Wi2U7VpQIDAQABoDIw
MAYJKoZIhvcNAQkOMSMwITAfBgNVHREEGDAWhwR/AAABgglsb2NhbGhvc3SCA2Ns
bjANBgkqhkiG9w0BAQsFAAOCAQEAWSvrwxJTRyK9mDwft4qsCCStS3Gunh7OSGeG
da+Lcrk4NABr3SnWwl/k3HEm2ZNt4/k0eDB95ElvUUcXoC5ptlyuVup97GZyO2VG
SqLcMlL3/vuGpkV6LGAbiLbcKKqBWwV5mP+bThMRfLJdiUpjoTrjiaHElfu8Be4e
qKzHYKbmSn7I7eDixsSsySPVL8h+YIamMH68BNi2AEJJH+Yyd/J7qZbP0zu9X2lQ
I09gdw49FEjx38Se8Jol7GRdzJnqFElnRHKXpR+FwtmsFMh+4Syc9Zz1fgOuBA6B
HR+OGRqO2jT+aINPB0x+GgKWvjt/DXxvF0tq42Q8/P4i9in95w==
MIICmDCCAYACAQAwUzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx
ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEMMAoGA1UEAwwDY2xu
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt+KCYNwj+YJNQXlJwuFi
MvJK5nU1DOcp9dvo4gvBClzwOeaRPsB4qnkWLxZqQ8+/q+LMD7P+QUXHqI49b8SF
m7FFEpCL5GEN090F1+GIEUBgEqMPHP3QRfyhnI4hmo0fgGArI7BMhBZrYH7nRKSN
oEgpWd8dtm9NR/jx7j9sWXtFx6LZoM6yZZXVy6DdDGNk7WR0o98fR3NtT+OzUAkC
MJR45aT7SYUNadCVNcFKsJqVMq0HDoa4sW1u184Fg5Bx38+gDXGn4VsDXwVXNMNx
mGNY7ADn+AiRq0MLGDu6B5a+Raqt+LAlvvsqbFww8wtWUHQ2gnvXZBEVS+Wi2U7V
pQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAD1yimwCgeFZwkSyzFYrzf9e68L8
Or8Fo7w5Wa1dtiGLz8hyHL5hephUgR5xA92VJ6zIXFXWYMfOvEFvRPCmXMjnS1Y5
hxfLo+6NJ+U4qX301Dd3NEKCDqxYQIoRVxY+fqZBYn7vbVwimmdf3Epn8CX/tvSq
49YWkq2yRDUrcOzvvt6wfG6Gv1N2Igra2op3A0LywLXkPYNFmvMOvXzA9/Tk5dGb
LNH1aIYbgMgA9XL7g1XVHqOqIBjqTukwVUJ6o/MVx8T9eoqZQRfV1s92BkhfnugS
XQ16AO45gXNnjz0T1OjXLHzYpXIlSneo1BMIh9/eAi3d5PihBk2ss8eo3U4=
-----END CERTIFICATE REQUEST-----

View File

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

View File

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

View File

@ -81,7 +81,6 @@ export const ITEM_FIELDS = gql`
confirmedAt
}
cost
meCommentsViewedAt
}`
export const ITEM_FULL_FIELDS = gql`
@ -92,15 +91,12 @@ export const ITEM_FULL_FIELDS = gql`
text
root {
id
createdAt
title
bounty
bountyPaidTo
subName
mine
ncomments
lastCommentAt
meCommentsViewedAt
user {
id
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`
query nameAvailable($name: String!) {
nameAvailable(name: $name)

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import crossFetch from 'cross-fetch'
import fetch from 'cross-fetch'
import crypto from 'crypto'
import { getAgent } from '@/lib/proxy'
import { assertContentTypeJson, assertResponseOk } from './url'
import { fetchWithTimeout, FetchTimeoutError } from './fetch'
import { FetchTimeoutError } from './fetch'
import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from './constants'
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'
let res
try {
res = await crossFetch(url, {
res = await fetch(url, {
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,
body: JSON.stringify({
// CLN requires a unique label for every invoice
@ -46,42 +52,6 @@ export const createInvoice = async ({ msats, description, expiry }, { socket, ru
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
export const decodeRune = (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
function cacheComment (cache, newComment, { live = false }) {
return cache.modify({
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
// updates the ncomments field of all ancestors of an item/comment in the cache
export function updateAncestorsCommentCount (cache, ancestors, increment) {
// update all ancestors
ancestors.forEach(id => {
cache.modify({
id: `Item:${id}`,
fields: {
ncomments (existingNComments = 0) {
return existingNComments + ncomments
return existingNComments + increment
}
},
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 TERRITORY_BILLING_TYPES = ['MONTHLY', 'YEARLY', 'ONCE']
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 FULL_COMMENTS_THRESHOLD = 200
export const COMMENTS_OF_COMMENT_LIMIT = 2
@ -59,14 +59,13 @@ export const INV_PENDING_LIMIT = 100
export const USER_ID = {
k00b: 616,
ek: 6030,
sox: 26458,
sn: 4502,
anon: 27,
ad: 9,
delete: 106,
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 MAX_POLL_NUM_CHOICES = 10
export const MIN_POLL_NUM_CHOICES = 2
@ -184,18 +183,16 @@ export const LOST_BLURBS = {
export const ADMIN_ITEMS = [
// FAQ, old privacy policy, changelog, content guidelines, tos, new privacy policy, copyright policy
349, 76894, 78763, 81862, 338393, 338369, 338453,
// wallet guides: LNbits, phoenixd, Alby Hub, Coinos
1212223, 1212375, 1215565, 1230105
349, 76894, 78763, 81862, 338393, 338369, 338453
]
export const INVOICE_RETENTION_DAYS = 7
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 NORMAL_POLL_INTERVAL_MS = Number(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS)
export const LONG_POLL_INTERVAL_MS = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL_MS)
export const EXTRA_LONG_POLL_INTERVAL_MS = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS)
export const FAST_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL)
export const NORMAL_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL)
export const LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL)
export const EXTRA_LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL)
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) {
if (!item.parentId && item.lastCommentAt) {
// 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
if (!item.parentId) {
const viewedAt = commentsViewedAt(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 {}
}
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) {
if (!href) return null
@ -143,7 +132,7 @@ export function parseEmbedUrl (href) {
id: searchParams.get('v'),
meta: {
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
meta: {
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))
}
export const searchSchema = object({
q: string().trim().max(100, 'must be at most 100 characters')
})
export function advPostSchemaMembers ({ me, existingBoost = 0, ...args }) {
const boostMin = existingBoost || BOOST_MIN
return {

View File

@ -1,6 +1,6 @@
const { withPlausibleProxy } = require('next-plausible')
const { InjectManifest } = require('workbox-webpack-plugin')
const CopyPlugin = require('copy-webpack-plugin')
const { generatePrecacheManifest } = require('./sw/build.js')
const webpack = require('webpack')
let isProd = process.env.NODE_ENV === 'production'
@ -215,24 +215,15 @@ module.exports = withPlausibleProxy()({
},
webpack: (config, { isServer, dev, defaultLoaders }) => {
if (isServer) {
generatePrecacheManifest()
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',
swSrc: './sw/index.js',
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
// from lib/constants.js
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_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_FAST_POLL_INTERVAL_MS': JSON.stringify(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL_MS),
'process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS': JSON.stringify(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS),
'process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL_MS': JSON.stringify(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL_MS),
'process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS': JSON.stringify(process.env.NEXT_PUBLIC_EXTRA_LONG_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': JSON.stringify(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL),
'process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL': JSON.stringify(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL),
'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.NEXT_IS_EXPORT_WORKER': 'true'
})

347
package-lock.json generated
View File

@ -20,7 +20,6 @@
"@nostr-dev-kit/ndk-wallet": "^0.5.0",
"@opensearch-project/opensearch": "^2.12.0",
"@prisma/client": "^5.20.0",
"@shocknet/clink-sdk": "^1.4.0",
"@slack/web-api": "^7.6.0",
"@svgr/webpack": "^8.1.0",
"@yudiel/react-qr-scanner": "^2.0.8",
@ -36,7 +35,6 @@
"classnames": "^2.5.1",
"clipboard-copy": "^4.0.1",
"cookie": "^1.0.1",
"copy-webpack-plugin": "^13.0.1",
"cross-fetch": "^4.0.0",
"csv-parser": "^3.0.0",
"domino": "^2.1.6",
@ -3237,31 +3235,11 @@
"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": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
@ -3278,6 +3256,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"engines": {
"node": ">=12"
},
@ -3289,6 +3268,7 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"engines": {
"node": ">=12"
},
@ -3299,12 +3279,14 @@
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
"version": "9.2.2",
"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": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
@ -3321,6 +3303,7 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"dependencies": {
"ansi-regex": "^6.0.1"
},
@ -3335,6 +3318,7 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
@ -5284,137 +5268,6 @@
"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": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@ -6655,23 +6508,6 @@
"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": {
"version": "3.5.2",
"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",
"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": {
"version": "3.38.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.1.tgz",
@ -8454,10 +8236,10 @@
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@ -9139,7 +8921,8 @@
"node_modules/eastasianwidth": {
"version": "0.2.0",
"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": {
"version": "0.1.2",
@ -10451,23 +10234,6 @@
"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": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -10611,12 +10377,12 @@
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
"integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.6",
"cross-spawn": "^7.0.0",
"signal-exit": "^4.0.1"
},
"engines": {
@ -10630,6 +10396,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"engines": {
"node": ">=14"
},
@ -10953,6 +10720,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.3"
},
@ -12226,7 +11994,8 @@
"node_modules/isexe": {
"version": "2.0.0",
"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": {
"version": "5.0.0",
@ -16017,6 +15786,7 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"engines": {
"node": ">=16 || 14 >=14.17"
}
@ -16285,6 +16055,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -16299,18 +16070,19 @@
}
},
"node_modules/nostr-tools": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.16.2.tgz",
"integrity": "sha512-ZxH9EbSt5ypURZj2TGNJxZd0Omb5ag5KZSu8IyJMCdLyg2KKz+2GA0sP/cSawCQEkyviIN4eRT4G2gB/t9lMRw==",
"license": "Unlicense",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.8.0.tgz",
"integrity": "sha512-aumZBa9Ok/cAJLovSBCIA/DkJjLjF/Hs5DpQGEjmyfaUkGBqd5jZjzalcVMyy/9HkkRZfJmbTPtqHTKFNvBSHQ==",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1",
"nostr-wasm": "0.1.0"
"@scure/bip39": "1.2.1"
},
"optionalDependencies": {
"nostr-wasm": "v0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
@ -16346,7 +16118,8 @@
"node_modules/nostr-wasm": {
"version": "0.1.0",
"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": {
"version": "4.0.1",
@ -16726,12 +16499,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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
@ -16827,6 +16594,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -18844,6 +18612,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@ -18855,6 +18624,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -19511,6 +19281,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@ -19630,6 +19401,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@ -19905,34 +19677,6 @@
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"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": {
"version": "6.1.51",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.51.tgz",
@ -20461,9 +20205,9 @@
"integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g=="
},
"node_modules/typescript": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -21326,6 +21070,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"dependencies": {
"isexe": "^2.0.0"
},
@ -21678,6 +21423,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@ -21694,6 +21440,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@ -21708,6 +21455,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
@ -21718,7 +21466,8 @@
"node_modules/wrap-ansi-cjs/node_modules/color-name": {
"version": "1.1.4",
"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": {
"version": "4.3.0",

View File

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

View File

@ -20,7 +20,6 @@ import { ChainFeeProvider } from '@/components/chain-fee.js'
import dynamic from 'next/dynamic'
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
import WalletsProvider from '@/wallets/client/context'
import FaviconProvider from '@/components/favicon'
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('routeChangeError', nprogressDone)
const handleServiceWorkerMessage = (event) => {
if (event.data?.type === 'navigate') {
router.push(event.data.url)
}
}
navigator.serviceWorker?.addEventListener('message', handleServiceWorkerMessage)
if (!props?.apollo) return
// HACK: 'cause there's no way to tell Next to skip SSR
// 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('routeChangeComplete', nprogressDone)
router.events.off('routeChangeError', nprogressDone)
navigator.serviceWorker?.removeEventListener('message', handleServiceWorkerMessage)
}
}, [router.asPath, props?.apollo, shouldShowProgressBar])
@ -113,7 +121,6 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<MeProvider me={me}>
<WalletsProvider>
<HasNewNotesProvider>
<FaviconProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<AnimationProvider>
@ -132,7 +139,6 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
</AnimationProvider>
</PriceProvider>
</ServiceWorkerProvider>
</FaviconProvider>
</HasNewNotesProvider>
</WalletsProvider>
</MeProvider>

View File

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

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