Compare commits
55 Commits
566a5f9675
...
15bd1c3fc5
Author | SHA1 | Date | |
---|---|---|---|
|
15bd1c3fc5 | ||
|
77781e07ed | ||
|
3cdf5c9451 | ||
|
a4cce7afed | ||
|
1afadbdf3b | ||
|
bdd87e7d39 | ||
|
e6081ebef3 | ||
|
5af61f415f | ||
|
1ce88a216a | ||
|
a913c2d452 | ||
|
54afe67558 | ||
|
34aadba352 | ||
|
a8b3ee37bf | ||
|
64bbd2e1b8 | ||
|
c01f4865dc | ||
|
2732013da3 | ||
|
f90ed8d294 | ||
|
5c2aa979ea | ||
|
ac321be3cd | ||
|
95e98501ec | ||
|
ee8fe6e72a | ||
|
9885bcf209 | ||
|
2dfde257d2 | ||
|
be7c702602 | ||
|
89187db1ea | ||
|
074f0c0634 | ||
|
efdcbef733 | ||
|
33beb1dc52 | ||
|
bb916b8669 | ||
|
312f4defb0 | ||
|
01b021a337 | ||
|
bd84b8bf88 | ||
|
965e482ea3 | ||
|
f8fa0f65e7 | ||
|
8059945f82 | ||
|
ee2d076d1b | ||
|
156b895fb6 | ||
|
53b8f6f956 | ||
|
c023e8d7d5 | ||
|
b28407ee99 | ||
|
78533bda1b | ||
|
47faef872d | ||
|
ca7726fda5 | ||
|
ae1942ada7 | ||
|
a92215ccf6 | ||
|
382714e422 | ||
|
1942c79193 | ||
|
355abc7221 | ||
|
181cb87c18 | ||
|
0c0fdfb63b | ||
|
020b914d0d | ||
|
0a83a88e06 | ||
|
ac7bd5df7e | ||
|
1057fcc04d | ||
|
c6de7a1081 |
@ -1,7 +1,7 @@
|
||||
import { cachedFetcher } from '@/lib/fetch'
|
||||
import { toPositiveNumber } from '@/lib/format'
|
||||
import { authenticatedLndGrpc } from '@/lib/lnd'
|
||||
import { getIdentity, getHeight, getWalletInfo, getNode, getPayment } from 'ln-service'
|
||||
import { getIdentity, getHeight, getWalletInfo, getNode, getPayment, parsePaymentRequest } from 'ln-service'
|
||||
import { datePivot } from '@/lib/time'
|
||||
import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
|
||||
|
||||
@ -23,11 +23,34 @@ getWalletInfo({ lnd }, (err, result) => {
|
||||
})
|
||||
|
||||
export async function estimateRouteFee ({ lnd, destination, tokens, mtokens, request, timeout }) {
|
||||
// if the payment request includes us as route hint, we needd to use the destination and amount
|
||||
// otherwise, this will fail with a self-payment error
|
||||
if (request) {
|
||||
const inv = parsePaymentRequest({ request })
|
||||
const ourPubkey = await getOurPubkey({ lnd })
|
||||
if (Array.isArray(inv.routes)) {
|
||||
for (const route of inv.routes) {
|
||||
if (Array.isArray(route)) {
|
||||
for (const hop of route) {
|
||||
if (hop.public_key === ourPubkey) {
|
||||
console.log('estimateRouteFee ignoring self-payment route')
|
||||
request = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const params = {}
|
||||
|
||||
if (request) {
|
||||
console.log('estimateRouteFee using payment request')
|
||||
params.payment_request = request
|
||||
} else {
|
||||
console.log('estimateRouteFee using destination and amount')
|
||||
params.dest = Buffer.from(destination, 'hex')
|
||||
params.amt_sat = tokens ? toPositiveNumber(tokens) : toPositiveNumber(BigInt(mtokens) / BigInt(1e3))
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers } from '@/lib/webPush'
|
||||
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers, notifyThreadSubscribers } from '@/lib/webPush'
|
||||
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||
import { GqlInputError } from '@/lib/error'
|
||||
@ -13,9 +13,33 @@ export const paymentMethods = [
|
||||
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||
]
|
||||
|
||||
export const DEFAULT_ITEM_COST = 1000n
|
||||
|
||||
export async function getBaseCost ({ models, bio, parentId, subName }) {
|
||||
if (bio) return DEFAULT_ITEM_COST
|
||||
|
||||
if (parentId) {
|
||||
// the subname is stored in the root item of the thread
|
||||
const parent = await models.item.findFirst({
|
||||
where: { id: Number(parentId) },
|
||||
include: {
|
||||
root: { include: { sub: true } },
|
||||
sub: true
|
||||
}
|
||||
})
|
||||
|
||||
const root = parent.root ?? parent
|
||||
|
||||
if (!root.sub) return DEFAULT_ITEM_COST
|
||||
return satsToMsats(root.sub.replyCost)
|
||||
}
|
||||
|
||||
const sub = await models.sub.findUnique({ where: { name: subName } })
|
||||
return satsToMsats(sub.baseCost)
|
||||
}
|
||||
|
||||
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
|
||||
const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } })
|
||||
const baseCost = sub ? satsToMsats(sub.baseCost) : 1000n
|
||||
const baseCost = await getBaseCost({ models, bio, parentId, subName })
|
||||
|
||||
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + upload fees + boost
|
||||
const [{ cost }] = await models.$queryRaw`
|
||||
@ -235,7 +259,9 @@ export async function onPaid ({ invoice, id }, context) {
|
||||
SET ncomments = "Item".ncomments + 1,
|
||||
"lastCommentAt" = GREATEST("Item"."lastCommentAt", comment.created_at),
|
||||
"weightedComments" = "Item"."weightedComments" +
|
||||
CASE WHEN comment."userId" = "Item"."userId" THEN 0 ELSE comment.trust END
|
||||
CASE WHEN comment."userId" = "Item"."userId" THEN 0 ELSE comment.trust END,
|
||||
"nDirectComments" = "Item"."nDirectComments" +
|
||||
CASE WHEN comment."parentId" = "Item".id THEN 1 ELSE 0 END
|
||||
FROM comment
|
||||
WHERE "Item".path @> comment.path AND "Item".id <> comment.id
|
||||
RETURNING "Item".*
|
||||
@ -259,6 +285,7 @@ export async function nonCriticalSideEffects ({ invoice, id }, { models }) {
|
||||
|
||||
if (item.parentId) {
|
||||
notifyItemParents({ item, models }).catch(console.error)
|
||||
notifyThreadSubscribers({ models, item }).catch(console.error)
|
||||
}
|
||||
for (const { userId } of item.mentions) {
|
||||
notifyMention({ models, item, userId }).catch(console.error)
|
||||
|
@ -36,6 +36,7 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
|
||||
|
||||
if (sub.userId !== me.id) {
|
||||
await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } })
|
||||
await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } })
|
||||
}
|
||||
|
||||
await tx.subAct.create({
|
||||
@ -47,6 +48,23 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
|
||||
}
|
||||
})
|
||||
|
||||
await tx.subSubscription.upsert({
|
||||
where: {
|
||||
userId_subName: {
|
||||
userId: me.id,
|
||||
subName: name
|
||||
}
|
||||
},
|
||||
update: {
|
||||
userId: me.id,
|
||||
subName: name
|
||||
},
|
||||
create: {
|
||||
userId: me.id,
|
||||
subName: name
|
||||
}
|
||||
})
|
||||
|
||||
return await tx.sub.update({
|
||||
data,
|
||||
// optimistic concurrency control
|
||||
|
@ -9,7 +9,10 @@ import {
|
||||
USER_ID, POLL_COST, ADMIN_ITEMS, GLOBAL_SEED,
|
||||
NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS,
|
||||
BOOST_MULT,
|
||||
ITEM_EDIT_SECONDS
|
||||
ITEM_EDIT_SECONDS,
|
||||
COMMENTS_LIMIT,
|
||||
COMMENTS_OF_COMMENT_LIMIT,
|
||||
FULL_COMMENTS_THRESHOLD
|
||||
} from '@/lib/constants'
|
||||
import { msatsToSats } from '@/lib/format'
|
||||
import { parse } from 'tldts'
|
||||
@ -25,39 +28,76 @@ import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||
import { verifyHmac } from './wallet'
|
||||
|
||||
function commentsOrderByClause (me, models, sort) {
|
||||
const sharedSortsArray = []
|
||||
sharedSortsArray.push('("Item"."pinId" IS NOT NULL) DESC')
|
||||
sharedSortsArray.push('("Item"."deletedAt" IS NULL) DESC')
|
||||
const sharedSorts = sharedSortsArray.join(', ')
|
||||
|
||||
if (sort === 'recent') {
|
||||
return 'ORDER BY ("Item"."deletedAt" IS NULL) DESC, ("Item".cost > 0 OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0) DESC, "Item".created_at DESC, "Item".id DESC'
|
||||
return `ORDER BY ${sharedSorts},
|
||||
("Item".cost > 0 OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0) DESC,
|
||||
COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC`
|
||||
}
|
||||
|
||||
if (me && sort === 'hot') {
|
||||
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, COALESCE(
|
||||
personal_hot_score,
|
||||
${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST,
|
||||
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
return `ORDER BY ${sharedSorts},
|
||||
"personal_hot_score" DESC NULLS LAST,
|
||||
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
} else {
|
||||
if (sort === 'top') {
|
||||
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0 })} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
return `ORDER BY ${sharedSorts}, ${orderByNumerator({ models, commentScaler: 0 })} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
} else {
|
||||
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
return `ORDER BY ${sharedSorts}, ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function comments (me, models, id, sort) {
|
||||
async function comments (me, models, item, sort, cursor) {
|
||||
const orderBy = commentsOrderByClause(me, models, sort)
|
||||
|
||||
if (me) {
|
||||
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id}) `
|
||||
const [{ item_comments_zaprank_with_me: comments }] = await models.$queryRawUnsafe(
|
||||
'SELECT item_comments_zaprank_with_me($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5, $6)',
|
||||
Number(id), GLOBAL_SEED, Number(me.id), COMMENT_DEPTH_LIMIT, filter, orderBy)
|
||||
return comments
|
||||
if (item.nDirectComments === 0) {
|
||||
return {
|
||||
comments: [],
|
||||
cursor: null
|
||||
}
|
||||
}
|
||||
|
||||
const filter = ' AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = \'PAID\') '
|
||||
const [{ item_comments: comments }] = await models.$queryRawUnsafe(
|
||||
'SELECT item_comments($1::INTEGER, $2::INTEGER, $3, $4)', Number(id), COMMENT_DEPTH_LIMIT, filter, orderBy)
|
||||
return comments
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
const offset = decodedCursor.offset
|
||||
|
||||
// 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".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)',
|
||||
Number(item.id), GLOBAL_SEED, Number(me.id), COMMENTS_LIMIT, offset, COMMENTS_OF_COMMENT_LIMIT, COMMENT_DEPTH_LIMIT, filter, orderBy)
|
||||
comments = limitedComments
|
||||
} else {
|
||||
const [{ item_comments_zaprank_with_me: fullComments }] = await models.$queryRawUnsafe(
|
||||
'SELECT item_comments_zaprank_with_me($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5, $6)',
|
||||
Number(item.id), GLOBAL_SEED, Number(me.id), COMMENT_DEPTH_LIMIT, filter, orderBy)
|
||||
comments = fullComments
|
||||
}
|
||||
} else {
|
||||
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)',
|
||||
Number(item.id), COMMENTS_LIMIT, offset, COMMENTS_OF_COMMENT_LIMIT, COMMENT_DEPTH_LIMIT, filter, orderBy)
|
||||
comments = limitedComments
|
||||
} else {
|
||||
const [{ item_comments: fullComments }] = await models.$queryRawUnsafe(
|
||||
'SELECT item_comments($1::INTEGER, $2::INTEGER, $3, $4)', Number(item.id), COMMENT_DEPTH_LIMIT, filter, orderBy)
|
||||
comments = fullComments
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
comments,
|
||||
cursor: comments.length + offset < item.nDirectComments ? nextCursorEncoded(decodedCursor, COMMENTS_LIMIT) : null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getItem (parent, { id }, { me, models }) {
|
||||
@ -412,10 +452,10 @@ export default {
|
||||
typeClause(type),
|
||||
muteClause(me)
|
||||
)}
|
||||
ORDER BY "Item".created_at DESC
|
||||
ORDER BY COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC
|
||||
OFFSET $2
|
||||
LIMIT $3`,
|
||||
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
||||
orderBy: 'ORDER BY COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC'
|
||||
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
||||
break
|
||||
case 'top':
|
||||
@ -536,8 +576,8 @@ export default {
|
||||
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
|
||||
${joinZapRankPersonalView(me, models)}
|
||||
${whereClause(
|
||||
// in "home" (sub undefined), we want to show pinned items (but without the pin icon)
|
||||
sub ? '"Item"."pinId" IS NULL' : '',
|
||||
// in home (sub undefined), filter out global pinned items since we inject them later
|
||||
sub ? '"Item"."pinId" IS NULL' : 'NOT ("Item"."pinId" IS NOT NULL AND "Item"."subName" IS NULL)',
|
||||
'"Item"."deletedAt" IS NULL',
|
||||
'"Item"."parentId" IS NULL',
|
||||
'"Item".outlawed = false',
|
||||
@ -565,8 +605,8 @@ export default {
|
||||
${whereClause(
|
||||
subClause(sub, 3, 'Item', me, showNsfw),
|
||||
muteClause(me),
|
||||
// in "home" (sub undefined), we want to show pinned items (but without the pin icon)
|
||||
sub ? '"Item"."pinId" IS NULL' : '',
|
||||
// in home (sub undefined), filter out global pinned items since we inject them later
|
||||
sub ? '"Item"."pinId" IS NULL' : 'NOT ("Item"."pinId" IS NOT NULL AND "Item"."subName" IS NULL)',
|
||||
'"Item"."deletedAt" IS NULL',
|
||||
'"Item"."parentId" IS NULL',
|
||||
'"Item".bio = false',
|
||||
@ -1053,6 +1093,9 @@ export default {
|
||||
}
|
||||
},
|
||||
Item: {
|
||||
invoicePaidAt: async (item, args, { models }) => {
|
||||
return item.invoicePaidAtUTC ?? item.invoicePaidAt
|
||||
},
|
||||
sats: async (item, args, { models, me }) => {
|
||||
if (me?.id === item.userId) {
|
||||
return msatsToSats(BigInt(item.msats))
|
||||
@ -1173,11 +1216,25 @@ export default {
|
||||
}
|
||||
})
|
||||
},
|
||||
comments: async (item, { sort }, { me, models }) => {
|
||||
if (typeof item.comments !== 'undefined') return item.comments
|
||||
if (item.ncomments === 0) return []
|
||||
comments: async (item, { sort, cursor }, { me, models }) => {
|
||||
if (typeof item.comments !== 'undefined') {
|
||||
if (Array.isArray(item.comments)) {
|
||||
return {
|
||||
comments: item.comments,
|
||||
cursor: null
|
||||
}
|
||||
}
|
||||
return item.comments
|
||||
}
|
||||
|
||||
return comments(me, models, item.id, sort || defaultCommentSort(item.pinId, item.bioId, item.createdAt))
|
||||
if (item.ncomments === 0) {
|
||||
return {
|
||||
comments: [],
|
||||
cursor: null
|
||||
}
|
||||
}
|
||||
|
||||
return comments(me, models, item, sort || defaultCommentSort(item.pinId, item.bioId, item.createdAt), cursor)
|
||||
},
|
||||
freedFreebie: async (item) => {
|
||||
return item.weightedVotes - item.weightedDownVotes > 0
|
||||
|
@ -467,6 +467,24 @@ export default {
|
||||
return subAct.subName
|
||||
}
|
||||
},
|
||||
ReferralSource: {
|
||||
__resolveType: async (n, args, { models }) => n.type
|
||||
},
|
||||
Referral: {
|
||||
source: async (n, args, { models, me }) => {
|
||||
// retrieve the referee landing record
|
||||
const referral = await models.oneDayReferral.findFirst({ where: { refereeId: Number(n.id), landing: true } })
|
||||
if (!referral) return null // if no landing record, it will return a generic referral
|
||||
|
||||
switch (referral.type) {
|
||||
case 'POST':
|
||||
case 'COMMENT': return { ...await getItem(n, { id: referral.typeId }, { models, me }), type: 'Item' }
|
||||
case 'TERRITORY': return { ...await getSub(n, { name: referral.typeId }, { models, me }), type: 'Sub' }
|
||||
case 'PROFILE': return { ...await models.user.findUnique({ where: { id: Number(referral.typeId) }, select: { name: true } }), type: 'User' }
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
},
|
||||
Streak: {
|
||||
days: async (n, args, { models }) => {
|
||||
const res = await models.$queryRaw`
|
||||
|
@ -107,6 +107,7 @@ export default gql`
|
||||
id: ID!
|
||||
createdAt: Date!
|
||||
updatedAt: Date!
|
||||
invoicePaidAt: Date
|
||||
deletedAt: Date
|
||||
deleteScheduledAt: Date
|
||||
reminderScheduledAt: Date
|
||||
@ -144,7 +145,8 @@ export default gql`
|
||||
bio: Boolean!
|
||||
paidImgLink: Boolean
|
||||
ncomments: Int!
|
||||
comments(sort: String): [Item!]!
|
||||
nDirectComments: Int!
|
||||
comments(sort: String, cursor: String): Comments!
|
||||
path: String
|
||||
position: Int
|
||||
prior: Int
|
||||
|
@ -124,9 +124,12 @@ export default gql`
|
||||
withdrawl: Withdrawl!
|
||||
}
|
||||
|
||||
union ReferralSource = Item | Sub | User
|
||||
|
||||
type Referral {
|
||||
id: ID!
|
||||
sortTime: Date!
|
||||
source: ReferralSource
|
||||
}
|
||||
|
||||
type SubStatus {
|
||||
|
@ -16,6 +16,7 @@ export default gql`
|
||||
|
||||
extend type Mutation {
|
||||
upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!,
|
||||
replyCost: Int!,
|
||||
postTypes: [String!]!,
|
||||
billingType: String!, billingAutoRenew: Boolean!,
|
||||
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
|
||||
@ -24,7 +25,7 @@ export default gql`
|
||||
toggleSubSubscription(name: String!): Boolean!
|
||||
transferTerritory(subName: String!, userName: String!): Sub
|
||||
unarchiveTerritory(name: String!, desc: String, baseCost: Int!,
|
||||
postTypes: [String!]!,
|
||||
replyCost: Int!, postTypes: [String!]!,
|
||||
billingType: String!, billingAutoRenew: Boolean!,
|
||||
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
|
||||
}
|
||||
@ -45,6 +46,7 @@ export default gql`
|
||||
billedLastAt: Date!
|
||||
billPaidUntil: Date
|
||||
baseCost: Int!
|
||||
replyCost: Int!
|
||||
status: String!
|
||||
moderated: Boolean!
|
||||
moderatedCount: Int!
|
||||
|
20
awards.csv
20
awards.csv
@ -160,9 +160,17 @@ Darth-Coin,issue,#1649,#1421,medium,,,,25k,darthcoin@stacker.news,2024-12-07
|
||||
Soxasora,pr,#1685,,medium,,,,250k,soxasora@blink.sv,2024-12-07
|
||||
aegroto,pr,#1606,#1242,medium,,,,250k,aegroto@blink.sv,2024-12-07
|
||||
sfr0xyz,issue,#1696,#1196,good-first-issue,,,,2k,sefiro@getalby.com,2024-12-10
|
||||
Soxasora,pr,#1794,#756,hard,urgent,,,3m,bolt11,2024-01-09
|
||||
Soxasora,pr,#1794,#411,hard,high,sort of grouped with #1794,,1m,bolt11,2024-01-09
|
||||
SatsAllDay,issue,#1749,#411,hard,high,,,200k,weareallsatoshi@getalby.com,???
|
||||
Soxasora,pr,#1786,#363,easy,,,,100k,soxasora@blink.sv,???
|
||||
felipebueno,issue,#1786,#363,easy,,,,10k,felipebueno@getalby.com,???
|
||||
cyphercosmo,pr,#1745,#1648,good-first-issue,,,2,16k,cyphercosmo@getalby.com,???
|
||||
Soxasora,pr,#1794,#756,hard,urgent,,includes #411,3m,bolt11,2025-01-09
|
||||
Soxasora,pr,#1786,#363,easy,,,,100k,bolt11,2025-01-09
|
||||
Soxasora,pr,#1768,#1186,medium-hard,,,,500k,bolt11,2025-01-09
|
||||
Soxasora,pr,#1750,#1035,medium,,,,250k,bolt11,2025-01-09
|
||||
SatsAllDay,issue,#1794,#411,hard,high,,,200k,weareallsatoshi@getalby.com,2025-01-20
|
||||
felipebueno,issue,#1786,#363,easy,,,,10k,felipebueno@blink.sv,2025-01-27
|
||||
cyphercosmo,pr,#1745,#1648,good-first-issue,,,2,16k,cyphercosmo@getalby.com,2025-01-27
|
||||
Radentor,issue,#1768,#1186,medium-hard,,,,50k,revisedbird84@walletofsatoshi.com,2025-01-27
|
||||
Soxasora,pr,#1841,#1692,good-first-issue,,,,20k,soxasora@blink.sv,2025-01-27
|
||||
Soxasora,pr,#1839,#1790,easy,,,1,90k,soxasora@blink.sv,2025-01-27
|
||||
Soxasora,pr,#1820,#1819,easy,,,1,90k,soxasora@blink.sv,2025-01-27
|
||||
SatsAllDay,issue,#1820,#1819,easy,,,1,9k,weareallsatoshi@getalby.com,2025-01-27
|
||||
Soxasora,pr,#1814,#1736,easy,,,,100k,soxasora@blink.sv,2025-01-27
|
||||
jason-me,pr,#1857,,easy,,,,100k,rrbtc@vlt.ge,2025-02-08
|
||||
|
|
@ -73,7 +73,7 @@ export function BountyForm ({
|
||||
hint={
|
||||
editThreshold
|
||||
? (
|
||||
<div className='text-muted fw-bold'>
|
||||
<div className='text-muted fw-bold font-monospace'>
|
||||
<Countdown date={editThreshold} />
|
||||
</div>
|
||||
)
|
||||
|
@ -2,7 +2,7 @@ import itemStyles from './item.module.css'
|
||||
import styles from './comment.module.css'
|
||||
import Text, { SearchText } from './text'
|
||||
import Link from 'next/link'
|
||||
import Reply, { ReplyOnAnotherPage } from './reply'
|
||||
import Reply from './reply'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import UpVote from './upvote'
|
||||
import Eye from '@/svgs/eye-fill.svg'
|
||||
@ -27,6 +27,7 @@ import Pin from '@/svgs/pushpin-fill.svg'
|
||||
import LinkToContext from './link-to-context'
|
||||
import Boost from './boost-button'
|
||||
import { gql, useApolloClient } from '@apollo/client'
|
||||
import classNames from 'classnames'
|
||||
|
||||
function Parent ({ item, rootText }) {
|
||||
const root = useRoot()
|
||||
@ -81,6 +82,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
|
||||
<LinkToContext
|
||||
className='py-2'
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
router.push(href, as)
|
||||
}}
|
||||
href={href}
|
||||
@ -128,6 +130,10 @@ export default function Comment ({
|
||||
// HACK wait for other comments to uncollapse if they're collapsed
|
||||
setTimeout(() => {
|
||||
ref.current.scrollIntoView({ behavior: 'instant', block: 'start' })
|
||||
// make sure we can outline a comment again if it was already outlined before
|
||||
ref.current.addEventListener('animationend', () => {
|
||||
ref.current.classList.remove('outline-it')
|
||||
}, { once: true })
|
||||
ref.current.classList.add('outline-it')
|
||||
}, 100)
|
||||
}
|
||||
@ -141,7 +147,7 @@ export default function Comment ({
|
||||
}
|
||||
}, [item.id])
|
||||
|
||||
const bottomedOut = depth === COMMENT_DEPTH_LIMIT
|
||||
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
|
||||
const op = root.user.name === item.user.name && Number(item.user.id) !== USER_ID.anon
|
||||
? 'OP'
|
||||
@ -243,7 +249,7 @@ export default function Comment ({
|
||||
</div>
|
||||
{collapse !== 'yep' && (
|
||||
bottomedOut
|
||||
? <div className={styles.children}><ReplyOnAnotherPage item={item} /></div>
|
||||
? <div className={styles.children}><div className={classNames(styles.comment, 'mt-3')}><ReplyOnAnotherPage item={item} /></div></div>
|
||||
: (
|
||||
<div className={styles.children}>
|
||||
{item.outlawed && !me?.privates?.wildWestMode
|
||||
@ -254,11 +260,17 @@ export default function Comment ({
|
||||
</Reply>}
|
||||
{children}
|
||||
<div className={styles.comments}>
|
||||
{item.comments && !noComments
|
||||
? item.comments.map((item) => (
|
||||
<Comment depth={depth + 1} key={item.id} item={item} />
|
||||
))
|
||||
{!noComments && item.comments?.comments
|
||||
? (
|
||||
<>
|
||||
{item.comments.comments.map((item) => (
|
||||
<Comment depth={depth + 1} key={item.id} item={item} />
|
||||
))}
|
||||
{item.comments.comments.length < item.nDirectComments && <ViewAllReplies id={item.id} nhas={item.ncomments} />}
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{/* TODO: add link to more comments if they're limited */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -267,6 +279,34 @@ export default function Comment ({
|
||||
)
|
||||
}
|
||||
|
||||
export function ViewAllReplies ({ id, nshown, nhas }) {
|
||||
const text = `view all ${nhas} replies`
|
||||
|
||||
return (
|
||||
<div className={`d-block fw-bold ${styles.comment} pb-2 ps-3`}>
|
||||
<Link href={`/items/${id}`} as={`/items/${id}`} className='text-muted'>
|
||||
{text}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReplyOnAnotherPage ({ item }) {
|
||||
const root = useRoot()
|
||||
const rootId = commentSubTreeRootId(item, root)
|
||||
|
||||
let text = 'reply on another page'
|
||||
if (item.ncomments > 0) {
|
||||
text = `view all ${item.ncomments} replies`
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/items/${rootId}?commentId=${item.id}`} as={`/items/${rootId}`} className='d-block pb-2 fw-bold text-muted'>
|
||||
{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommentSkeleton ({ skeletonChildren }) {
|
||||
return (
|
||||
<div className={styles.comment}>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Fragment } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import Comment, { CommentSkeleton } from './comment'
|
||||
import styles from './header.module.css'
|
||||
import Nav from 'react-bootstrap/Nav'
|
||||
@ -6,6 +6,8 @@ import Navbar from 'react-bootstrap/Navbar'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
import { defaultCommentSort } from '@/lib/item'
|
||||
import { useRouter } from 'next/router'
|
||||
import MoreFooter from './more-footer'
|
||||
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
|
||||
|
||||
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
||||
const router = useRouter()
|
||||
@ -60,10 +62,13 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
|
||||
)
|
||||
}
|
||||
|
||||
export default function Comments ({ parentId, pinned, bio, parentCreatedAt, commentSats, comments, ...props }) {
|
||||
export default function Comments ({
|
||||
parentId, pinned, bio, parentCreatedAt,
|
||||
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, ...props
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position)
|
||||
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -91,6 +96,12 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
|
||||
{comments.filter(({ position }) => !position).map(item => (
|
||||
<Comment depth={1} key={item.id} item={item} {...props} />
|
||||
))}
|
||||
{ncomments > FULL_COMMENTS_THRESHOLD &&
|
||||
<MoreFooter
|
||||
cursor={commentsCursor} fetchMore={fetchMoreComments} noMoreText=' '
|
||||
count={comments?.length}
|
||||
Skeleton={CommentsSkeleton}
|
||||
/>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -76,7 +76,7 @@ export function DiscussionForm ({
|
||||
name='text'
|
||||
minRows={6}
|
||||
hint={editThreshold
|
||||
? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div>
|
||||
? <div className='text-muted fw-bold font-monospace'><Countdown date={editThreshold} /></div>
|
||||
: null}
|
||||
/>
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub} />
|
||||
|
@ -208,10 +208,6 @@ export default function Footer ({ links = true }) {
|
||||
story
|
||||
</Link>
|
||||
<span className='mx-2 text-muted'> \ </span>
|
||||
<Link href='/changes' className='nav-link p-0 p-0 d-inline-flex'>
|
||||
changes
|
||||
</Link>
|
||||
<span className='mx-2 text-muted'> \ </span>
|
||||
<OverlayTrigger trigger='click' placement='top' overlay={LegalPopover} rootClose>
|
||||
<div className='nav-link p-0 p-0 d-inline-flex' style={{ cursor: 'pointer' }}>
|
||||
legal
|
||||
|
@ -486,7 +486,7 @@ function FormGroup ({ className, label, children }) {
|
||||
|
||||
function InputInner ({
|
||||
prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue,
|
||||
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength,
|
||||
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, hideError,
|
||||
...props
|
||||
}) {
|
||||
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
|
||||
@ -574,7 +574,7 @@ function InputInner ({
|
||||
onKeyDown={onKeyDownInner}
|
||||
onChange={onChangeInner}
|
||||
onBlur={onBlurInner}
|
||||
isInvalid={invalid}
|
||||
isInvalid={!hideError && invalid} // if hideError is true, handle error showing separately
|
||||
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
|
||||
/>
|
||||
{(isClient && clear && field.value && !props.readOnly) &&
|
||||
@ -1241,5 +1241,118 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, value: ini
|
||||
)
|
||||
}
|
||||
|
||||
export function MultiInput ({
|
||||
name, label, groupClassName, length = 4, charLength = 1, upperCase, showSequence,
|
||||
onChange, autoFocus, hideError, inputType = 'text',
|
||||
...props
|
||||
}) {
|
||||
const [inputs, setInputs] = useState(new Array(length).fill(''))
|
||||
const inputRefs = useRef(new Array(length).fill(null))
|
||||
const [, meta, helpers] = useField({ name })
|
||||
|
||||
useEffect(() => {
|
||||
autoFocus && inputRefs.current[0].focus() // focus the first input if autoFocus is true
|
||||
}, [autoFocus])
|
||||
|
||||
const updateInputs = useCallback((newInputs) => {
|
||||
setInputs(newInputs)
|
||||
const combinedValue = newInputs.join('') // join the inputs to get the value
|
||||
helpers.setValue(combinedValue) // set the value to the formik field
|
||||
onChange?.(combinedValue)
|
||||
}, [onChange, helpers])
|
||||
|
||||
const handleChange = useCallback((formik, e, index) => { // formik is not used but it's required to get the value
|
||||
const value = e.target.value.slice(-charLength)
|
||||
const processedValue = upperCase ? value.toUpperCase() : value // convert the input to uppercase if upperCase is tru
|
||||
|
||||
const newInputs = [...inputs]
|
||||
newInputs[index] = processedValue
|
||||
updateInputs(newInputs)
|
||||
|
||||
// focus the next input if the current input is filled
|
||||
if (processedValue.length === charLength && index < length - 1) {
|
||||
inputRefs.current[index + 1].focus()
|
||||
}
|
||||
}, [inputs, charLength, upperCase, onChange, length])
|
||||
|
||||
const handlePaste = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
const pastedValues = e.clipboardData.getData('text').slice(0, length)
|
||||
const processedValues = upperCase ? pastedValues.toUpperCase() : pastedValues
|
||||
const chars = processedValues.split('')
|
||||
|
||||
const newInputs = [...inputs]
|
||||
chars.forEach((char, i) => {
|
||||
newInputs[i] = char.slice(0, charLength)
|
||||
})
|
||||
|
||||
updateInputs(newInputs)
|
||||
inputRefs.current[length - 1]?.focus() // simulating the paste by focusing the last input
|
||||
}, [inputs, length, charLength, upperCase, updateInputs])
|
||||
|
||||
const handleKeyDown = useCallback((e, index) => {
|
||||
switch (e.key) {
|
||||
case 'Backspace': {
|
||||
e.preventDefault()
|
||||
const newInputs = [...inputs]
|
||||
// if current input is empty move focus to the previous input else clear the current input
|
||||
const targetIndex = inputs[index] === '' && index > 0 ? index - 1 : index
|
||||
newInputs[targetIndex] = ''
|
||||
updateInputs(newInputs)
|
||||
inputRefs.current[targetIndex]?.focus()
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
if (index > 0) { // focus the previous input if it's not the first input
|
||||
e.preventDefault()
|
||||
inputRefs.current[index - 1]?.focus()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
if (index < length - 1) { // focus the next input if it's not the last input
|
||||
e.preventDefault()
|
||||
inputRefs.current[index + 1]?.focus()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [inputs, length, updateInputs])
|
||||
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
<div className='d-flex flex-row justify-content-center gap-2'>
|
||||
{inputs.map((value, index) => (
|
||||
<InputInner
|
||||
inputGroupClassName='w-auto'
|
||||
name={name}
|
||||
key={index}
|
||||
type={inputType}
|
||||
value={value}
|
||||
innerRef={(el) => { inputRefs.current[index] = el }}
|
||||
onChange={(formik, e) => handleChange(formik, e, index)}
|
||||
onKeyDown={e => handleKeyDown(e, index)}
|
||||
onPaste={e => handlePaste(e, index)}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
maxWidth: `${charLength * 44}px` // adjusts the max width of the input based on the charLength
|
||||
}}
|
||||
prepend={showSequence && <InputGroup.Text>{index + 1}</InputGroup.Text>} // show the index of the input
|
||||
hideError
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</FormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export const ClientInput = Client(Input)
|
||||
export const ClientCheckbox = Client(Checkbox)
|
||||
|
@ -8,7 +8,7 @@ export default function CCInfo (props) {
|
||||
<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, recepients will receive CCs regardless of their configured receiving wallet</li>
|
||||
<li>if the zaps are split on a post, recipients will receive CCs regardless of their configured receiving wallet</li>
|
||||
<li>there could be an issue paying your receiving wallet
|
||||
<ul>
|
||||
<li>if the zap is small and you don't have a direct channel to SN, the routing fee may exceed SN's 3% max fee</li>
|
||||
|
@ -160,7 +160,7 @@ function ItemText ({ item }) {
|
||||
: <Text itemId={item.id} topLevel rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>{item.text}</Text>
|
||||
}
|
||||
|
||||
export default function ItemFull ({ item, bio, rank, ...props }) {
|
||||
export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props }) {
|
||||
useEffect(() => {
|
||||
commentsViewed(item)
|
||||
}, [item.lastCommentAt])
|
||||
@ -186,7 +186,11 @@ export default function ItemFull ({ item, bio, rank, ...props }) {
|
||||
<div className={styles.comments}>
|
||||
<Comments
|
||||
parentId={item.id} parentCreatedAt={item.createdAt}
|
||||
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
|
||||
pinned={item.position} bio={bio} commentSats={item.commentSats}
|
||||
ncomments={item.ncomments}
|
||||
comments={item.comments.comments}
|
||||
commentsCursor={item.comments.cursor}
|
||||
fetchMoreComments={fetchMoreComments}
|
||||
/>
|
||||
</div>}
|
||||
</CarouselProvider>
|
||||
|
@ -135,8 +135,8 @@ export default function ItemInfo ({
|
||||
{embellishUser}
|
||||
</Link>}
|
||||
<span> </span>
|
||||
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
|
||||
{timeSince(new Date(item.createdAt))}
|
||||
<Link href={`/items/${item.id}`} title={item.invoicePaidAt || item.createdAt} className='text-reset' suppressHydrationWarning>
|
||||
{timeSince(new Date(item.invoicePaidAt || item.createdAt))}
|
||||
</Link>
|
||||
{item.prior &&
|
||||
<>
|
||||
@ -193,8 +193,7 @@ export default function ItemInfo ({
|
||||
)}
|
||||
{item && item.mine && !item.noteId && !item.isJob && !item.parentId &&
|
||||
<CrosspostDropdownItem item={item} />}
|
||||
{me && !item.position &&
|
||||
!item.mine && !item.deletedAt &&
|
||||
{me && !item.mine && !item.deletedAt &&
|
||||
(item.meDontLikeSats > meSats
|
||||
? <DropdownItemUpVote item={item} />
|
||||
: <DontLikeThisDropdownItem item={item} />)}
|
||||
@ -250,6 +249,11 @@ function InfoDropdownItem ({ item }) {
|
||||
<div>{item.id}</div>
|
||||
<div>created at</div>
|
||||
<div>{item.createdAt}</div>
|
||||
{item.invoicePaidAt &&
|
||||
<>
|
||||
<div>paid at</div>
|
||||
<div>{item.invoicePaidAt}</div>
|
||||
</>}
|
||||
<div>cost</div>
|
||||
<div>{item.cost}</div>
|
||||
<div>stacked</div>
|
||||
@ -343,7 +347,7 @@ function EditInfo ({ item, edit, canEdit, setCanEdit, toggleEdit, editText, edit
|
||||
<>
|
||||
<span> \ </span>
|
||||
<span
|
||||
className='text-reset pointer fw-bold'
|
||||
className='text-reset pointer fw-bold font-monospace'
|
||||
onClick={() => toggleEdit ? toggleEdit() : router.push(`/items/${item.id}/edit`)}
|
||||
>
|
||||
<span>{editText || 'edit'} </span>
|
||||
@ -364,7 +368,7 @@ function EditInfo ({ item, edit, canEdit, setCanEdit, toggleEdit, editText, edit
|
||||
<>
|
||||
<span> \ </span>
|
||||
<span
|
||||
className='text-reset pointer fw-bold'
|
||||
className='text-reset pointer fw-bold font-monospace'
|
||||
onClick={() => toggleEdit ? toggleEdit() : router.push(`/items/${item.id}`)}
|
||||
>
|
||||
<span>cancel </span>
|
||||
|
@ -25,6 +25,8 @@ import { decodeProxyUrl, IMGPROXY_URL_REGEXP, parseInternalLinks } from '@/lib/u
|
||||
import ItemPopover from './item-popover'
|
||||
import { useMe } from './me'
|
||||
import Boost from './boost-button'
|
||||
import { useShowModal } from './modal'
|
||||
import { BoostHelp } from './adv-post-form'
|
||||
|
||||
function onItemClick (e, router, item) {
|
||||
const viewedAt = commentsViewedAt(item)
|
||||
@ -87,10 +89,11 @@ function ItemLink ({ url, rel }) {
|
||||
|
||||
export default function Item ({
|
||||
item, rank, belowTitle, right, full, children, itemClassName,
|
||||
onQuoteReply, pinnable, setDisableRetry, disableRetry
|
||||
onQuoteReply, pinnable, setDisableRetry, disableRetry, ad
|
||||
}) {
|
||||
const titleRef = useRef()
|
||||
const router = useRouter()
|
||||
const showModal = useShowModal()
|
||||
|
||||
const media = mediaType({ url: item.url, imgproxyUrls: item.imgproxyUrls })
|
||||
const MediaIcon = media === 'video' ? VideoIcon : ImageIcon
|
||||
@ -138,7 +141,15 @@ export default function Item ({
|
||||
full={full} item={item}
|
||||
onQuoteReply={onQuoteReply}
|
||||
pinnable={pinnable}
|
||||
extraBadges={Number(item?.user?.id) === USER_ID.ad && <Badge className={styles.newComment} bg={null}>AD</Badge>}
|
||||
extraBadges={ad &&
|
||||
<>{' '}
|
||||
<Badge
|
||||
className={classNames(styles.newComment, 'pointer')}
|
||||
bg={null} onClick={() => showModal(() => <BoostHelp />)}
|
||||
>
|
||||
top boost
|
||||
</Badge>
|
||||
</>}
|
||||
setDisableRetry={setDisableRetry}
|
||||
disableRetry={disableRetry}
|
||||
/>
|
||||
|
@ -136,7 +136,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
||||
autoComplete='off'
|
||||
overrideValue={data?.pageTitleAndUnshorted?.unshorted}
|
||||
hint={editThreshold
|
||||
? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div>
|
||||
? <div className='text-muted fw-bold font-monospace'><Countdown date={editThreshold} /></div>
|
||||
: null}
|
||||
onChange={async (formik, e) => {
|
||||
const hasTitle = !!(formik?.values.title.trim().length > 0)
|
||||
|
@ -20,6 +20,7 @@ export function EmailLoginForm ({ text, callbackUrl, multiAuth }) {
|
||||
}}
|
||||
schema={emailSchema}
|
||||
onSubmit={async ({ email }) => {
|
||||
window.sessionStorage.setItem('callback', JSON.stringify({ email, callbackUrl }))
|
||||
signIn('email', { email, callbackUrl, multiAuth })
|
||||
}}
|
||||
>
|
||||
@ -41,7 +42,7 @@ const authErrorMessages = {
|
||||
OAuthCallback: 'Error handling OAuth response. Try again or choose a different method.',
|
||||
OAuthCreateAccount: 'Could not create OAuth account. Try again or choose a different method.',
|
||||
EmailCreateAccount: 'Could not create Email account. Try again or choose a different method.',
|
||||
Callback: 'Error in callback handler. Try again or choose a different method.',
|
||||
Callback: 'Try again or choose a different method.',
|
||||
OAuthAccountNotLinked: 'This auth method is linked to another account. To link to this account first unlink the other account.',
|
||||
EmailSignin: 'Failed to send email. Make sure you entered your email address correctly.',
|
||||
CredentialsSignin: 'Auth failed. Try again or choose a different method.',
|
||||
|
@ -133,8 +133,12 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
|
||||
// hack
|
||||
// if it's not a video it will throw an error, so we can assume it's an image
|
||||
const img = new window.Image()
|
||||
img.onload = () => setIsImage(true)
|
||||
img.src = src
|
||||
img.decode().then(() => { // decoding beforehand to prevent wrong image cropping
|
||||
setIsImage(true)
|
||||
}).catch((e) => {
|
||||
console.error('Cannot decode image', e)
|
||||
})
|
||||
}
|
||||
video.src = src
|
||||
|
||||
|
@ -44,6 +44,7 @@ import classNames from 'classnames'
|
||||
import HolsterIcon from '@/svgs/holster.svg'
|
||||
import SaddleIcon from '@/svgs/saddle.svg'
|
||||
import CCInfo from './info/cc'
|
||||
import { useMe } from './me'
|
||||
|
||||
function Notification ({ n, fresh }) {
|
||||
const type = n.__typename
|
||||
@ -528,11 +529,27 @@ function WithdrawlPaid ({ n }) {
|
||||
}
|
||||
|
||||
function Referral ({ n }) {
|
||||
const { me } = useMe()
|
||||
let referralSource = 'of you'
|
||||
switch (n.source?.__typename) {
|
||||
case 'Item':
|
||||
referralSource = (Number(me?.id) === Number(n.source.user?.id) ? 'of your' : 'you shared this') + ' ' + (n.source.title ? 'post' : 'comment')
|
||||
break
|
||||
case 'Sub':
|
||||
referralSource = (Number(me?.id) === Number(n.source.userId) ? 'of your' : 'you shared the') + ' ~' + n.source.name + ' territory'
|
||||
break
|
||||
case 'User':
|
||||
referralSource = (me?.name === n.source.name ? 'of your profile' : `you shared ${n.source.name}'s profile`)
|
||||
break
|
||||
}
|
||||
return (
|
||||
<small className='fw-bold text-success'>
|
||||
<UserAdd className='fill-success me-2' height={21} width={21} style={{ transform: 'rotateY(180deg)' }} />someone joined SN because of you
|
||||
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||
</small>
|
||||
<>
|
||||
<small className='fw-bold text-success'>
|
||||
<UserAdd className='fill-success me-1' height={21} width={21} style={{ transform: 'rotateY(180deg)' }} />someone joined SN because {referralSource}
|
||||
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||
</small>
|
||||
{n.source?.__typename === 'Item' && <NoteItem itemClassName='pt-2' item={n.source} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -58,7 +58,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
||||
max={MAX_POLL_NUM_CHOICES}
|
||||
min={2}
|
||||
hint={editThreshold
|
||||
? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div>
|
||||
? <div className='text-muted fw-bold font-monospace'><Countdown date={editThreshold} /></div>
|
||||
: null}
|
||||
maxLength={MAX_POLL_CHOICE_LENGTH}
|
||||
/>
|
||||
|
@ -44,6 +44,15 @@ export function PriceProvider ({ price, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AccessibleButton ({ id, description, children, ...props }) {
|
||||
return (
|
||||
<div>
|
||||
<button {...props} aria-describedby={id}>{children}</button>
|
||||
<div id={id} className='visually-hidden'>{description}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Price ({ className }) {
|
||||
const [selection, handleClick] = usePriceCarousel()
|
||||
|
||||
@ -56,53 +65,53 @@ export default function Price ({ className }) {
|
||||
if (selection === 'yep') {
|
||||
if (!price || price < 0) return null
|
||||
return (
|
||||
<div className={compClassName} onClick={handleClick} variant='link'>
|
||||
<AccessibleButton id='yep-hint' description='Show 1 satoshi equals 1 satoshi' className={compClassName} onClick={handleClick} variant='link'>
|
||||
{fixedDecimal(100000000 / price, 0) + ` sats/${fiatSymbol}`}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
)
|
||||
}
|
||||
|
||||
if (selection === '1btc') {
|
||||
return (
|
||||
<div className={compClassName} onClick={handleClick} variant='link'>
|
||||
<AccessibleButton id='1btc-hint' description='Show blockheight' className={compClassName} onClick={handleClick} variant='link'>
|
||||
1sat=1sat
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
)
|
||||
}
|
||||
|
||||
if (selection === 'blockHeight') {
|
||||
if (blockHeight <= 0) return null
|
||||
return (
|
||||
<div className={compClassName} onClick={handleClick} variant='link'>
|
||||
<AccessibleButton id='blockHeight-hint' description='Show fee rate' className={compClassName} onClick={handleClick} variant='link'>
|
||||
{blockHeight}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
)
|
||||
}
|
||||
|
||||
if (selection === 'halving') {
|
||||
if (!halving) return null
|
||||
return (
|
||||
<div className={compClassName} onClick={handleClick} variant='link'>
|
||||
<AccessibleButton id='halving-hint' description='Show fiat price' className={compClassName} onClick={handleClick} variant='link'>
|
||||
<CompactLongCountdown date={halving} />
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
)
|
||||
}
|
||||
|
||||
if (selection === 'chainFee') {
|
||||
if (chainFee <= 0) return null
|
||||
return (
|
||||
<div className={compClassName} onClick={handleClick} variant='link'>
|
||||
<AccessibleButton id='chainFee-hint' description='Show time until halving' className={compClassName} onClick={handleClick} variant='link'>
|
||||
{chainFee} sat/vB
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
)
|
||||
}
|
||||
|
||||
if (selection === 'fiat') {
|
||||
if (!price || price < 0) return null
|
||||
return (
|
||||
<div className={compClassName} onClick={handleClick} variant='link'>
|
||||
<AccessibleButton id='fiat-hint' description='Show price in satoshis per fiat unit' className={compClassName} onClick={handleClick} variant='link'>
|
||||
{fiatSymbol + fixedDecimal(price, 0)}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ 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 Link from 'next/link'
|
||||
import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
|
||||
import { commentsViewedAfterComment } from '@/lib/new-comments'
|
||||
import { commentSchema } from '@/lib/validate'
|
||||
@ -11,26 +10,10 @@ import { ItemButtonBar } from './post'
|
||||
import { useShowModal } from './modal'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useRoot } from './root'
|
||||
import { commentSubTreeRootId } from '@/lib/item'
|
||||
import { CREATE_COMMENT } from '@/fragments/paidAction'
|
||||
import useItemSubmit from './use-item-submit'
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export function ReplyOnAnotherPage ({ item }) {
|
||||
const rootId = commentSubTreeRootId(item)
|
||||
|
||||
let text = 'reply on another page'
|
||||
if (item.ncomments > 0) {
|
||||
text = 'view replies'
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/items/${rootId}?commentId=${item.id}`} as={`/items/${rootId}`} className='d-block py-3 fw-bold text-muted'>
|
||||
{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default forwardRef(function Reply ({
|
||||
item,
|
||||
replyOpen,
|
||||
@ -55,9 +38,9 @@ export default forwardRef(function Reply ({
|
||||
|
||||
const placeholder = useMemo(() => {
|
||||
return [
|
||||
'comment for currency?',
|
||||
'comment for currency',
|
||||
'fractions of a penny for your thoughts?',
|
||||
'put your money where your mouth is?'
|
||||
'put your money where your mouth is'
|
||||
][parentId % 3]
|
||||
}, [parentId])
|
||||
|
||||
@ -70,13 +53,16 @@ export default forwardRef(function Reply ({
|
||||
cache.modify({
|
||||
id: `Item:${parentId}`,
|
||||
fields: {
|
||||
comments (existingCommentRefs = []) {
|
||||
comments (existingComments = {}) {
|
||||
const newCommentRef = cache.writeFragment({
|
||||
data: result,
|
||||
fragment: COMMENTS,
|
||||
fragmentName: 'CommentsRecursive'
|
||||
})
|
||||
return [newCommentRef, ...existingCommentRefs]
|
||||
return {
|
||||
cursor: existingComments.cursor,
|
||||
comments: [newCommentRef, ...(existingComments?.comments || [])]
|
||||
}
|
||||
}
|
||||
},
|
||||
optimistic: true
|
||||
@ -175,7 +161,7 @@ export default forwardRef(function Reply ({
|
||||
{reply &&
|
||||
<div className={styles.reply}>
|
||||
<FeeButtonProvider
|
||||
baseLineItems={postCommentBaseLineItems({ baseCost: 1, comment: true, me: !!me })}
|
||||
baseLineItems={postCommentBaseLineItems({ baseCost: sub?.replyCost ?? 1, comment: true, me: !!me })}
|
||||
useRemoteLineItems={postCommentUseRemoteLineItems({ parentId: item.id, me: !!me })}
|
||||
>
|
||||
<Form
|
||||
|
@ -91,6 +91,7 @@ export default function TerritoryForm ({ sub }) {
|
||||
name: sub?.name || '',
|
||||
desc: sub?.desc || '',
|
||||
baseCost: sub?.baseCost || 10,
|
||||
replyCost: sub?.replyCost || 1,
|
||||
postTypes: sub?.postTypes || POST_TYPES,
|
||||
billingType: sub?.billingType || 'MONTHLY',
|
||||
billingAutoRenew: sub?.billingAutoRenew || false,
|
||||
@ -234,6 +235,13 @@ export default function TerritoryForm ({ sub }) {
|
||||
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
|
||||
body={
|
||||
<>
|
||||
<Input
|
||||
label='reply cost'
|
||||
name='replyCost'
|
||||
type='number'
|
||||
required
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<BootstrapForm.Label>moderation</BootstrapForm.Label>
|
||||
<Checkbox
|
||||
inline
|
||||
|
@ -57,9 +57,16 @@ export function TerritoryInfo ({ sub }) {
|
||||
<span> on </span>
|
||||
<span className='fw-bold'>{new Date(sub.createdAt).toDateString()}</span>
|
||||
</div>
|
||||
<div className='text-muted'>
|
||||
<span>post cost </span>
|
||||
<span className='fw-bold'>{numWithUnits(sub.baseCost)}</span>
|
||||
<div className='d-flex'>
|
||||
<div className='text-muted'>
|
||||
<span>post cost </span>
|
||||
<span className='fw-bold'>{numWithUnits(sub.baseCost)}</span>
|
||||
</div>
|
||||
<span className='px-1'> \ </span>
|
||||
<div className='text-muted'>
|
||||
<span>reply cost </span>
|
||||
<span className='fw-bold'>{numWithUnits(sub.replyCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<TerritoryBillingLine sub={sub} />
|
||||
</CardFooter>
|
||||
|
@ -200,6 +200,10 @@
|
||||
}
|
||||
|
||||
.p.onlyImages {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.p.onlyImages:has(> .mediaContainer.loaded) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
726
docs/user/faq.md
726
docs/user/faq.md
@ -6,209 +6,203 @@ sub: meta
|
||||
|
||||
# Stacker News FAQ
|
||||
|
||||
To quickly browse through this FAQ page, click the chapters icon in the top-right corner. This will let you scroll through all FAQ chapter titles or search for a particular topic within this page.
|
||||
_To quickly browse through this FAQ page, click the chapters icon in the top-right corner. This will let you scroll through all chapters or search for a particular topic within this page._
|
||||
|
||||
last updated: February 7, 2025
|
||||
|
||||
---
|
||||
|
||||
## New Stackers Start Here
|
||||
|
||||
‎
|
||||
##### What is Stacker News?
|
||||
### What is Stacker News?
|
||||
|
||||
Stacker News is a forum (like Reddit or Hacker News) where you can earn sats for creating or curating content. Rather than collecting “upvotes” that are not redeemable or transferable on Reddit or Hacker News, on Stacker News you can earn sats.
|
||||
Stacker News is a forum similar to Reddit or Hacker News. Unlike on Reddit or Hacker News where you earn "upvotes" or "karma" that are not redeemable or transferable, on Stacker News you earn satoshis for creating and curating content.
|
||||
|
||||
‎
|
||||
##### What Are Sats?
|
||||
### What are satoshis?
|
||||
|
||||
Sats are the smallest denomination of Bitcoin. Just like there are 100 pennies in 1 dollar, there are 100,000,000 sats in 1 Bitcoin. On Stacker News, all Bitcoin payments and balances are denominated in sats.
|
||||
A satoshi is the smallest denomination of bitcoin. Just like there are 100 pennies in 1 dollar, there are 100,000,000 satoshis in 1 bitcoin. Satoshis are commonly abbreviated as "sats".
|
||||
|
||||
‎
|
||||
##### Do I Need Bitcoin to Use Stacker News?
|
||||
On Stacker News, all bitcoin payments are denominated in sats and use the Lightning Network.
|
||||
|
||||
No. Every new stacker can comment for free (with limited visibility) while they earn their first few sats. After a stacker has started earning sats for their content, subsequent posts and comments will incur a small fee to prevent spam and to encourage quality contributions. Many stackers earn enough sats from their posts and comments to continue posting on the site indefinitely without ever depositing their own sats.
|
||||
### What are cowboy credits?
|
||||
|
||||
Post and comment fees vary depending on the [territory](https://stacker.news/faq#stacker-news-territories).
|
||||
Stacker News never takes custody of stackers' money to send it to someone else.
|
||||
|
||||
‎
|
||||
##### Why Is My Wallet Balance Going Up?
|
||||
To help new stackers get started without requiring them to [attach a lightning wallet](#how-do-i-attach-a-wallet), stackers without an attached wallet will earn cowboy credits (CCs) when other stackers zap their content. Stacker News will accept CCs instead of sats for any payment on the site at a 1:1 ratio. This means new stackers can use these earned CCs to pay for posts, comments, zaps, jobs, boosts, donations or even territories but cannot withdraw them.
|
||||
|
||||
When other stackers [zap](https://stacker.news/faq#zapping-on-stacker-news) your posts and comments, those sats go to you. Stackers who are actively contributing content and sats also earn extra sats as a daily reward. These sats come from the revenue generated by Stacker News from posting/commenting fees and boost fees.
|
||||
If you need additional cowboy credits beyond what you've earned through zaps, you can always purchase them with sats at a 1:1 ratio [here](/credits).
|
||||
|
||||
-----
|
||||
### What are zaps?
|
||||
|
||||
## Creating an Account
|
||||
Zaps are micropayments on the Lightning Network commonly used as tips.
|
||||
|
||||
‎
|
||||
##### How Do I Create a Stacker News Account?
|
||||
### What are territories?
|
||||
|
||||
The most private way to create a Stacker News account is by logging in with one of the Lightning wallets listed below.
|
||||
Every post on Stacker News belongs to a territory. Territories are communities where stackers gather to discuss shared interests and help them grow and thrive.
|
||||
|
||||
Lightning wallets for logging in to Stacker News:
|
||||
They are founded by stackers who pay us to receive the revenue they generate (some would call that a business model). Territories generate revenue because 70% of post, comment and boost fees and 21% of zaps go to the founder.
|
||||
|
||||
- Alby
|
||||
- Balance of Satoshis
|
||||
- Blixt
|
||||
- Breez
|
||||
- Coinos
|
||||
- LNbits
|
||||
- LNtxbot
|
||||
- Phoenix
|
||||
- SeedAuth
|
||||
- SeedAuthExtension
|
||||
- SimpleBitcoinWallet
|
||||
- ThunderHub
|
||||
- Zap Desktop
|
||||
- Zeus
|
||||
See the [section about territories](#territories) for details.
|
||||
|
||||
Alternatively, new stackers can set up an account by linking their email, Nostr, Github, or X accounts.
|
||||
### Do I need bitcoin to use Stacker News?
|
||||
|
||||
‎
|
||||
##### How Do I Login With Lightning?
|
||||
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.
|
||||
|
||||
To login with Lightning:
|
||||
[Post and comment fees vary depending on the territory](#why-does-it-cost-more-to-post-in-some-territories).
|
||||
|
||||
1. Click [Login](/login)
|
||||
2. Select [Login with Lightning](/login?type=lightning)
|
||||
3. Open one of the Lightning wallets listed above
|
||||
4. Scan the QR code that appears on Stacker News
|
||||
5. Confirm your log in attempt on your Lightning wallet
|
||||
### How do I earn sats on Stacker News?
|
||||
|
||||
‎
|
||||
##### Can I Use Multiple Login Methods?
|
||||
There are four ways to earn sats on Stacker News:
|
||||
|
||||
Yes.
|
||||
**1. Zaps**
|
||||
|
||||
Once you’re logged in, follow these steps to link other authentication methods:
|
||||
1. Click your username
|
||||
2. Click [settings](/settings)
|
||||
3. Scroll down to link other authentication methods
|
||||
To earn sats via [zaps](#zaps) from fellow stackers peer-to-peer, you need to [attach a wallet](#wallets) that can receive payments. Once you're setup, share interesting links, discussion prompts or simply engage with the community in comments. If another stacker finds value in what you shared and they also attached a wallet, you will receive real sats when they zap you.
|
||||
|
||||
Once you’ve linked another authentication method to your account, you’ll be able to access your account on any device using any one of your linked authentication methods.
|
||||
**2. Daily rewards**
|
||||
|
||||
‎
|
||||
##### Why Should I Log In With Lightning?
|
||||
Stackers can also earn sats via daily rewards. Stacker News uses the revenue it generates from post, comment, zap and boost fees, the job board and donations to reward stackers that contributed to the site with even more sats beyond the zaps they already received. Contributions also include zapping content since they are used as a signal for ranking. **You do not need to attach a wallet to receive daily rewards in sats. They are automatically deposited into your account.** You can find and withdraw your reward sats balance [here](/credits).
|
||||
|
||||
Logging in with Lightning is the most private method of logging in to Stacker News.
|
||||
**3. Referrals**
|
||||
|
||||
Rather than entering an email address, or linking your X or Github accounts, you can simply scan a QR code with your Lightning wallet or use a Lightning web wallet like Alby which enables desktop stackers to log in with a single click.
|
||||
Another way to earn sats is via [referrals](/referrals/month). If a stacker signs up through one of your referral links, you will earn 10% of their rewards in perpetuity. A referral link is any link that ends with /r/\<your name\>. Additionally, if a stacker clicks your referral links more than anyone else's on a given day, you will also receive 10% of their rewards for that day.
|
||||
|
||||
‎
|
||||
##### How Do I Set a Stacker News Username?
|
||||
Your posts, comments and profile are implicit referral links. They don't need to have the /r/\<your name\> suffix.
|
||||
|
||||
When setting up an account, Stacker News will automatically create a username for you.
|
||||
To make referring stackers easy, clicking on `...` next to a post or comment and selecting 'copy link' will copy it as a referral link by default. You can disable this in your [settings](/settings).
|
||||
|
||||
To change your username:
|
||||
**4. Territories**
|
||||
|
||||
1. Click your username (it's in the top-right corner of your screen)
|
||||
2. Select profile
|
||||
3. Click ‘edit nym’
|
||||
The last way to earn sats is by founding a territory since they generate revenue. However, this is not a recommended way to earn sats for new stackers since you need to pay for the territory in advance and it requires a lot of effort to just break even.
|
||||
|
||||
---
|
||||
|
||||
## Funding Your Account
|
||||
## Wallets
|
||||
|
||||
‎
|
||||
##### How Do I Fund My Stacker News Wallet?
|
||||
Stacker News is non-custodial. To send and receive sats, you need to attach a wallet. If you don't attach a wallet, you will send and receive [CCs](#what-are-cowboy-credits).
|
||||
|
||||
There are three ways to fund your Stacker News account:
|
||||
### How do I attach a wallet?
|
||||
|
||||
1. By QR code
|
||||
2. By Lightning Address
|
||||
3. By sharing great content
|
||||
Click [here](/wallets) or click on your name and select 'wallets'. You should then see this:
|
||||
|
||||
‎
|
||||
###### QR code
|
||||

|
||||
|
||||
1. Click your username
|
||||
2. Click [wallet](/wallet)
|
||||
3. Click fund
|
||||
4. Enter a payment amount
|
||||
5. Generate an invoice on Stacker News
|
||||
6. Pay the invoice on your Lightning wallet
|
||||
We currently support the following wallets:
|
||||
|
||||
‎
|
||||
###### Lightning Address
|
||||
- [WebLN](https://www.webln.guide/ressources/webln-providers)
|
||||
- [Blink](https://www.blink.sv/)
|
||||
- [Core Lightning](https://docs.corelightning.org/) via [CLNRest](https://docs.corelightning.org/docs/rest)
|
||||
- [Lightning Node Connect](https://docs.lightning.engineering/lightning-network-tools/lightning-terminal/lightning-node-connect) (LNC)
|
||||
- [Lightning Network Daemon](https://github.com/lightningnetwork/lnd) (LND) via [gRPC](https://lightning.engineering/api-docs/api/lnd/)
|
||||
- [LNbits](https://lnbits.com/)
|
||||
- [Nostr Wallet Connect](https://nwc.dev/) (NWC)
|
||||
- [lightning address](https://strike.me/learn/what-is-a-lightning-address/)
|
||||
- [phoenixd](https://phoenix.acinq.co/server)
|
||||
|
||||
1. Click your username
|
||||
2. Open a wallet that offers Lightning Address support
|
||||
3. Enter your Stacker News Lightning Address on your wallet
|
||||
4. Pay any amount to fund your Stacker News account
|
||||
Click on the wallet you want to attach and complete the form.
|
||||
|
||||
‎
|
||||
###### Sharing great content
|
||||
### I can't find my wallet. Can I not attach one?
|
||||
|
||||
Every new stacker gets free comments (with limited visibility) to get started on Stacker News. Many stackers have earned enough sats from their first few posts and comments to continue posting on the site indefinitely without ever depositing their own sats.
|
||||
We currently don't list every wallet individually but [this is planned](https://github.com/stackernews/stacker.news/issues/1495).
|
||||
|
||||
‎
|
||||
##### What Is a Lightning Address?
|
||||
If you can't find your wallet, there is still a high chance that you can attach one. Many wallets support Nostr Wallet Connect or provide lightning addresses. The following table shows how you can attach some common wallets:
|
||||
|
||||
A Lightning Address is just like an email address, but for your Bitcoin.
|
||||
| Wallet | Lightning Address | Nostr Wallet Connect |
|
||||
| --- | --- | --- |
|
||||
| [Strike](https://strike.me/) | ✅ | ❌ |
|
||||
| [cashu.me](https://cashu.me/) | ✅ | ✅ |
|
||||
| [Wallet of Satoshi](https://www.walletofsatoshi.com/) | ✅ | ❌ |
|
||||
| [Zebedee](https://zbd.gg/) | ✅ | ❌ |
|
||||
| [Coinos](https://coinos.io/) | ✅ | ✅ |
|
||||
|
||||
It is a simple tool that anyone can use to send Bitcoin without scanning QR codes or copying and pasting invoices between wallets.
|
||||
### What do the arrows mean?
|
||||
|
||||
For more on how Lightning Addresses work, [click here](https://lightningaddress.com/).
|
||||
Not every wallet supports both sending and receiving sats. For example, a lightning address can receive sats but not send them. This is indicated with an arrow to the bottom-left ↙️. A wallet that can send sats will have an arrow to the top-right ↗️.
|
||||
|
||||
‎
|
||||
##### Where Is My Stacker News Lightning Address?
|
||||
If you still can't attach a wallet, you can reach out to us in the [saloon](/daily) or simply reply to this FAQ.
|
||||
|
||||
All stackers get Lightning addresses, which follow the format of username@stacker.news.
|
||||
### I receive notifications about failed zaps. What do I do?
|
||||
|
||||
Your Lightning address can also be found on your profile page, highlighted with a yellow button and a Lightning bolt icon.
|
||||
This means your wallet isn't working properly. You can retry the payment or check your [wallet logs](/wallets/logs) for errors. A retry usually works.
|
||||
|
||||
‎
|
||||
##### How Do I See My Account Balance?
|
||||
If the retry didn't work, you can't find an error or don't understand it, let us know in the [saloon](/daily) or reply to this FAQ.
|
||||
|
||||
When logged in, your wallet balance is the number shown in the top-right corner of your screen.
|
||||
The link to the wallet logs can be found on the [wallet page](/wallets).
|
||||
|
||||
Clicking your wallet balance allows you to fund, withdraw, or view your past transactions.
|
||||
### Why do I need to enter two strings for NWC?
|
||||
|
||||
‎
|
||||
##### How Do I See My Transaction History?
|
||||
For security reasons, we never store permissions to spend from your wallet on the server in plain text.
|
||||
|
||||
To see your full history of Stacker News transactions:
|
||||
Since we however need to request invoices from your wallet when there is an incoming payment, we need to store the details to receive payments on the server in plaintext.
|
||||
|
||||
1. Click your wallet balance in the top-right corner of your screen
|
||||
2. Click [Wallet History](https://stacker.news/satistics?inc=invoice,withdrawal,stacked,spent)
|
||||
3. Select which data you would like to see from the top menu
|
||||
This means that the details for receiving cannot be mixed with the details for sending and is why we need two separate NWC strings for sending and receiving.
|
||||
|
||||
The buttons on your wallet history page allow you to view and filter your past funding invoices, withdrawals, as well as the transactions where you stacked sats or spent sats on Stacker News.
|
||||
Other applications don't require two strings for one of the following reasons:
|
||||
|
||||
-----
|
||||
1. they only use NWC for sending but not for receiving
|
||||
2. you can only receive while you are logged in
|
||||
3. they (irresponsibly) store permissions to spend in plaintext on their server
|
||||
|
||||
## Posting on Stacker News
|
||||
### Why is my wallet not showing up on another device?
|
||||
|
||||
‎
|
||||
##### How Do I Post?
|
||||
By default, permissions to spend from your wallet are only stored on your device.
|
||||
|
||||
To submit a post, click the Post button in the nav bar.
|
||||
However, you can enable [device sync](/settings/passphrase) in your settings to securely sync your wallets across devices. Once enabled, your wallets will show up on all devices you entered your passphrase.
|
||||
|
||||
Each post has a small fixed fee as a measure to limit spam, and to encourage stackers to post quality content.
|
||||
### I have a wallet attached but I still receive CCs. Why?
|
||||
|
||||
There are a few different types of posts stackers can make on Stacker News, including links, discussions, polls, and bounties.
|
||||
This can happen for any of the following reasons:
|
||||
|
||||
- Link posts require a title and a URL (stackers can optionally include a discussion prompt)
|
||||
- Discussion posts require a title and a discussion prompt (stackers can optionally add links to their discussion prompt)
|
||||
- Poll posts require a title and at least two poll options to choose from
|
||||
- Bounty posts require a title, prompt, and a bounty amount to be paid on task completion
|
||||
1. The sender did not have a wallet attached
|
||||
2. Sender's dust limit was too high for the outgoing zap amount ('send credits for zaps below' in [settings](/settings))
|
||||
3. Your dust limit was too high for the incoming zap amount ('receive credits for zaps and deposits below' in [settings](/settings))
|
||||
3. Sender's wallet was not able to pay
|
||||
4. Routing the payment to you was too expensive for the zap amount (3% are reserved for network fees)
|
||||
5. The zap was forwarded to you
|
||||
|
||||
‎
|
||||
##### How Do I Comment?
|
||||
### I have a wallet attached but I still send CCs. Why?
|
||||
|
||||
To comment on a post:
|
||||
This can happen for any of the following reasons:
|
||||
|
||||
1. Click the title of the post you want to comment on
|
||||
2. Submit your comment in the text box below the post
|
||||
1. The receiver did not have a wallet attached
|
||||
2. Your dust limit was too high for the outgoing zap amount ('send credits for zaps below' in [settings](/settings))
|
||||
3. Receiver's dust limit was too high for the incoming zap amount ('receive credits for zaps and deposits below' in [settings](/settings))
|
||||
4. Your wallet was not able to pay
|
||||
5. Routing the payment to the sender was too expensive for the zap amount (3% are reserved for network fees)
|
||||
6. The zap was forwarded to the receiver
|
||||
|
||||
To reply to a comment:
|
||||
### I don't want to receive CCs. How do I disable them?
|
||||
|
||||
1. Click reply beneath the comment you want to reply to
|
||||
2. Submit your comment in the text box below the comment
|
||||
You cannot disable receiving CCs but we might change that in the future. For now, you can donate any CCs you received [here](/rewards).
|
||||
|
||||
‎
|
||||
##### How Do Posting Fees Work?
|
||||
Post and comment fees vary depending on a few factors.
|
||||
---
|
||||
|
||||
First, territory owners have the ability to set their own post and comment fees.
|
||||
## Territories
|
||||
|
||||
Territories are communities on Stacker News. Each territory has a founder who acts as a steward of the community, and anyone can post content to the territory that best fits the topic of their post.
|
||||
|
||||
When Stacker News first launched without territories, much of the discussion focused exclusively on Bitcoin. However, since territories have been introduced, anyone can now create a thriving community on Stacker News to discuss any topic.
|
||||
|
||||
### How do I found a territory?
|
||||
|
||||
Click [here](/territory) or scroll to the bottom in the territory dropdown menu and click on 'create'.
|
||||
|
||||
### How much does it cost to found a territory?
|
||||
|
||||
Founding a territory costs either 50k sats/month, 500k sats/year, or 3m sats as a one-time payment.
|
||||
|
||||
If a territory founder chooses either the monthly or yearly payment options, they can select 'auto-renew' so that Stacker News is automatically paid the territory fee each month or year from your CC balance. If a territory founder doesn't select 'auto-renew' or they don't have enough CCs, they will get a notification to pay an invoice within 5 days after the end of their current billing period to keep their territory.
|
||||
|
||||
If you later change your mind, your payment for the current period is included in the new cost. This means that if you go from monthly to yearly payments for example, we will charge you 450k instead of 500k sats.
|
||||
|
||||
### Do I earn sats from territories?
|
||||
|
||||
Yes. Territory founders earn 70% of all posting and boost fees as well as 21% of all sats zapped within their territory. These earnings are paid out at the end of each day. You will receive a notification and you can withdraw your sats at any time [here](/credits).
|
||||
|
||||
The remaining 30% of posting and boost fees and 9% of zapped sats go to the Stacker News daily rewards pool, which rewards the best contributors each day.
|
||||
|
||||
### Why does it cost more to post in some territories?
|
||||
|
||||
Territory founders set the fees for posts and comments in their territories.
|
||||
|
||||
Additionally, fees increase by 10x for repetitive posts and self-reply comments to prevent spam.
|
||||
|
||||
@ -218,256 +212,94 @@ This 10x fee escalation continues until 10 minutes have elapsed, and will reset
|
||||
|
||||
This 10 minute fee escalation rule does not apply to stackers who are replying to other stackers, only those who repetitively post or reply to themselves within a single thread.
|
||||
|
||||
There are also fees for uploads but your first 250 MB within 24 hours are free. After that, every upload will cost 10 sats until you reach 500 MB. Then the fee is raised to 100 sats until 1 GB after which every upload will cost 1,000 sats. After 24 hours, you can upload 250 MB for free again. Uploads without being logged in always cost 100 sats.
|
||||
### Are media uploads free?
|
||||
|
||||
Upload fees are applied when you submit your post or comment. Uploaded content that isn't used within 24 hours in a post or comment is deleted.
|
||||
Your first 250 MB within 24 hours are free. After that, the following fees apply:
|
||||
|
||||
‎
|
||||
##### What Is a Boost?
|
||||
| uploaded within 24 hours | cost per upload |
|
||||
| -------------------------| --------------- |
|
||||
| up to 250 MB | 0 sats |
|
||||
| 250-500 MB | 10 sats |
|
||||
| 500-1 GB | 100 sats |
|
||||
| more than 1GB | 1,000 sats |
|
||||
|
||||
Boosts allow stackers to increase the ranking of their post upon creation to give their content more visibility.
|
||||
After 24 hours, you can upload 250 MB for free again.
|
||||
|
||||
‎
|
||||
##### How Do I Earn Sats on Stacker News?
|
||||
Uploads without being logged in always cost 100 sats.
|
||||
|
||||
Stackers reward each other for their contributions by zapping them with sats.
|
||||
Upload fees are applied when you submit your post or comment.
|
||||
|
||||
To start earning sats, you can share interesting links, discussion prompts, or comments with the community.
|
||||
### Are media uploads stored forever?
|
||||
|
||||
Beyond the direct payments from other stackers, Stacker News also uses the revenue it generates from its job board, boost fees, post fees, and stacker donations to reward stackers that contributed to the site with even more sats.
|
||||
Yes, if it was used in a post or comment. **Uploads that haven't been used within 24 hours in a post or comment are deleted.**
|
||||
|
||||
Every day, Stacker News rewards either creators or zappers with a daily reward. These rewards go to stackers who either created or zapped one or more of the top 33% of posts and comments from the previous day. The rewards scale with the ranking of the content as determined by other stackers.
|
||||
### I no longer want to pay for my territory. What should I do?
|
||||
|
||||
Finally, Stacker News also rewards stackers with sats for referring new stackers to the platform. To read more about the Stacker News referral program, click [here](https://stacker.news/items/349#how-does-the-stacker-news-referral-program-work).
|
||||
Make sure 'auto-renew' is disabled in your territory settings. After that, simply ignore the new bill at the end of your current billing period.
|
||||
|
||||
‎
|
||||
##### How Do I Format Posts on Stacker News?
|
||||
After the grace period of 5 days, the territory will be archived. Stackers can still see archived posts and comments, but they will not be able to create new posts or comments until someone pays for that territory again.
|
||||
|
||||
Stacker News uses [github flavored markdown](https://guides.github.com/features/mastering-markdown/) for styling all posts and comments.
|
||||
### How do I bring back a territory?
|
||||
|
||||
You can use any of the following elements in your content:
|
||||
Enter the name of the territory you want to bring back in the [territory form](/territory). If the territory indeed existed before, you will see a hint below the input field like this:
|
||||
|
||||
- Headings
|
||||
- Blockquotes
|
||||
- Unordered Lists
|
||||
- Ordered Lists
|
||||
- Inline code with syntax highlighting
|
||||
- Tables
|
||||
- Text Links
|
||||
- Line Breaks
|
||||
- Subscript or Superscript
|
||||

|
||||
|
||||
In addition, stackers can tag other stackers with the @ symbol like this: @sn. Stackers can also refer to different territories with the ~ symbol like this: ~jobs.
|
||||
The info text mentions that you will inherit all existing content.
|
||||
|
||||
‎
|
||||
##### How Do I Post Images or Videos on Stacker News?
|
||||
Other than that, the process to bring back an archived territory is the same as founding a new territory.
|
||||
|
||||
There are two ways to post images or videos:
|
||||
### I want to share the costs and revenue of a territory with someone. How do I do that?
|
||||
|
||||
1. By pasting a URL to an image or video
|
||||
2. By uploading an image or video
|
||||
You can't do that yet but this is planned. Currently, territories can only have a single founder.
|
||||
|
||||
If you have a URL, you can simply paste it into any textbox. Once your link is pasted into the textbox of a post or comment, it will automatically be rendered as an image or video when you preview or post.
|
||||
### What do the territory stats in my profile mean?
|
||||
|
||||
To upload files, click the upload icon on the top-right corner of the textbox. This will open a file explorer where you can select the files you want to upload (or multiple). We currently support following file types:
|
||||

|
||||
|
||||
- image/gif
|
||||
- image/heic
|
||||
- image/png
|
||||
- image/jpeg
|
||||
- image/webp
|
||||
- video/mp4
|
||||
- video/mpeg
|
||||
- video/webm
|
||||
The stats for each territory are the following:
|
||||
|
||||
Uploaded content that isn't used within 24 hours in a SN post or comment is deleted.
|
||||
- stacked: how many sats stackers stacked in this territory without the 30% sybil fee
|
||||
- revenue: how much revenue went to the founder
|
||||
- spent: how many sats have been spent in this territory on posts, comments, boosts, zaps, downzaps, jobs and poll votes
|
||||
- posts: the total number of posts in the territory
|
||||
- comments: the total number of comments in the territory
|
||||
|
||||
As explained in the [section about posting fees](https://stacker.news/faq#how-do-posting-fees-work), fees might apply for uploads.
|
||||
You can filter the same stats by different periods in [top territories](/top/territories/day).
|
||||
|
||||
To expand an image on Stacker News, click the image. Clicking it again will shrink it back to its original size.
|
||||
---
|
||||
|
||||
If you are trying to post images from Twitter on Stacker News, make sure you have selected the tweet's image URL, and not the tweet URL itself.
|
||||
## Zaps
|
||||
|
||||
To find the image URL of a twitter photo, right-click the image on Twitter, select "Open In New Tab", and copy that URL.
|
||||
### How do I zap on Stacker News?
|
||||
|
||||
‎
|
||||
##### Stacker News Shortcuts
|
||||
To send a zap, click the lightning bolt next to a post or comment. Each click will automatically send your default zap amount to the creator of the post or comment. You can zap a post or comment an unlimited number of times.
|
||||
|
||||
Stacker News supports a handful of useful keyboard shortcuts for saving time when creating content:
|
||||
### How do I change my default zap amount?
|
||||
|
||||
`ctrl+enter`: submit any post/comment/form
|
||||
`ctrl+k`: link in markdown fields
|
||||
`ctrl+i`: italics in markdown fields
|
||||
`ctrl+b`: bold in markdown fields
|
||||
`ctrl+alt+tab`: real tab in markdown fields
|
||||
You can change your default zap amount in your [settings](/settings).
|
||||
|
||||
-----
|
||||
### How do I zap a custom amount?
|
||||
|
||||
## Stacker News Territories
|
||||
To send a custom zap amount, long-press on the lightning bolt next to a post or comment until a textbox appears. Then type the number of sats you’d like to zap, and click 'zap'.
|
||||
|
||||
‎
|
||||
##### What are Territories?
|
||||
Your last five custom amounts are saved so you can quickly zap the same amount again.
|
||||
|
||||
Territories are communities on Stacker News. Each territory has an owner who acts as a steward of the community, and anyone can post content to the territory that best fits the topic of their post.
|
||||
|
||||
When Stacker News first launched, much of the discussion focused exclusively on Bitcoin. However, the launch of territories means anyone can now create a thriving community on Stacker News to discuss any topic.
|
||||
|
||||
‎
|
||||
##### Can Anyone Start a Territory?
|
||||
|
||||
Anyone can start a territory by clicking the dropdown menu next to the logo on the homepage, scrolling to the bottom of the list, and clicking [create](https://stacker.news/territory). Stackers can also create as many territories as they want.
|
||||
|
||||
‎
|
||||
##### How Much Does It Cost to Start a Territory?
|
||||
|
||||
Starting a territory costs either 100k sats/month, 1m sats/year, or 3m sats as a one-time payment.
|
||||
|
||||
If a territory owners chooses either the monthly or yearly payment options, they can select 'auto-renew' so that Stacker News is automatically paid the territory fee each month or year. If a territory owner doesn't select 'auto-renew', they will get a notification to pay an invoice within 5 days after the end of their month or year to keep their territory.
|
||||
|
||||
If you later change your mind, your payment for the current period is included in the new cost. This means that if you go from monthly to yearly payments for example, we will charge you 900k instead of 1m sats.
|
||||
|
||||
‎
|
||||
##### Can Territory Owners Earn Sats?
|
||||
|
||||
Yes, territory owners earn 70% of all fees generated by content in their specific territory. This means territory owners earn 7% of all sats zapped within their territory, as well as 70% of all sats paid as boosts or posting and commenting costs within their territory. These rewards are paid to territory owners each day as part of the Stacker News daily rewards.
|
||||
|
||||
The remaining 30% of fees generated by content in a given territory is paid to the Stacker News daily rewards pool, which rewards the best contributors on the site each day.
|
||||
|
||||
‎
|
||||
##### What Variables Do Territory Owners Control?
|
||||
|
||||
Territory owners can set the following variables for their territory:
|
||||
|
||||
- Territory name
|
||||
- Territory description
|
||||
- Minimum posting cost
|
||||
- Allowable post types
|
||||
|
||||
Territory owners can also mark their territory as NSFW or enable moderation. Moderation allows them to outlaw content with one click (see [How Do I Flag Content](https://stacker.news/faq#how-do-i-flag-content-i-dont-like)).
|
||||
|
||||
All territory variables can be updated after creation.
|
||||
|
||||
‎
|
||||
##### What Happens If I No Longer Want My Territory?
|
||||
|
||||
If a territory owner chooses not to renew their territory at the end of their billing period, the territory will be archived. Stackers can still see archived posts and comments, but they will not be able to create new posts or comments until someone takes ownership of the territory.
|
||||
|
||||
-----
|
||||
|
||||
## Discovering Content on Stacker News
|
||||
|
||||
‎
|
||||
##### How Do I Search on Stacker News?
|
||||
|
||||
To search for content on Stacker News, click the magnifying glass located in the navbar. This is a powerful feature that allows stackers to search for posts, comments, and other stackers across the site.
|
||||
|
||||
[Search results](https://stacker.news/search) can be filtered by the following metrics:
|
||||
|
||||
- best match
|
||||
- most recent
|
||||
- most comments
|
||||
- most sats
|
||||
- most votes
|
||||
|
||||
In addition, search results can be segmented over time, showing the relevant results from the past day, week, month, year, or forever.
|
||||
|
||||
Finally, there are some hidden search commands that can further assist you with identifying specific types of content on Stacker News:
|
||||
|
||||
`~territoryname` allows you to search within a specific territory
|
||||
`nym:ausersnym` allows you to search for items from a certain user by replacing `ausernym` with the nym you want to find
|
||||
`url:aurl` allows you to search for certain domain names by replacing `aurl` with a domain name you want to find
|
||||
|
||||
‎
|
||||
##### How Do I Subscribe to Someone on Stacker News?
|
||||
|
||||
If you find a stacker you want to see more content from, you can click their profile and then click the `...` icon next to their photo. There, you can choose to either subscribe to their posts or their comments.
|
||||
|
||||
Once subscribed, you'll get a notification each time they post content.
|
||||
|
||||
‎
|
||||
##### How Do I Subscribe to Posts on Stacker News?
|
||||
|
||||
If you find a post you want to follow along with, click the `...` icon next to the post metadata and select subscribe.
|
||||
|
||||
Once subscribed, you'll get a notification each time someone makes a comment on that post.
|
||||
|
||||
‎
|
||||
##### How Do I Mute on Stacker News?
|
||||
|
||||
If you want to mute a stacker, click the `...` icon next to one of their posts or the `...` icon on their profile page and select mute.
|
||||
|
||||
Once muted, you'll no longer see that stacker's content or get notified if they comment on your content.
|
||||
|
||||
‎
|
||||
##### How Do I Find New Territories on Stacker News?
|
||||
|
||||
Stacker News offers a number of territories, or topic-based collections of content.
|
||||
|
||||
To explore a particular territory on Stacker News, click the dropdown menu next to the Stacker News logo in the navbar and select the topic you'd like to see content on.
|
||||
|
||||
If you want to post content to a particular territory, the territory you're currently browsing will automatically be selected as the territory for your post.
|
||||
|
||||
If you wish to post your content in a different territory, simply select a new one from the dropdown on the post page and fill out your post details there.
|
||||
|
||||
-----
|
||||
|
||||
## Zapping on Stacker News
|
||||
|
||||
‎
|
||||
##### How Do I Zap on Stacker News?
|
||||
|
||||
To send a zap, click the Lightning bolt next to a post or comment. Each click will automatically send your default zap amount to the creator of the post or comment. You can zap a post or comment an unlimited number of times.
|
||||
|
||||
You can also zap any specific number of sats by either changing your default zap amount or by setting a custom zap amount on an individual piece of content.
|
||||
|
||||
‎
|
||||
##### How Do I Change My Default Zap Amount?
|
||||
|
||||
You can change your default zap amount in your settings:
|
||||
|
||||
1. Click your username
|
||||
2. Click [settings](/settings)
|
||||
3. Enter a new default zap amount
|
||||
|
||||
‎
|
||||
##### How Do I Zap a Custom Amount?
|
||||
|
||||
To send a custom zap amount, long-press on the Lightning bolt next to a post or comment until a textbox appears. Then type the number of sats you’d like to zap, and click zap.
|
||||
|
||||
‎
|
||||
##### Turbo Zaps
|
||||
|
||||
Turbo Zaps is an opt-in, experimental feature for improving zapping UX. When enabled in your settings, every Lightning bolt click on a specific post or comment raises your total zap to the next 10x of your default zap amount. If your default zap amount is 1 sat:
|
||||
|
||||
- your first click: 1 sat total zapped
|
||||
- your second click: 10 sats total zapped
|
||||
- your third click: 100 sats total zapped
|
||||
- your fourth click: 1000 sats total zapped
|
||||
- and so on...
|
||||
|
||||
Turbo zaps only escalate your zapping amount when you repeatedly click on the Lightning bolt of a specific post or comment. Zapping a new post or comment will once again start at your default zap amount, and escalate by 10x with every additional click.
|
||||
|
||||
Turbo zaps is a convenient way to modify your zap amounts on the go, rather than relying on a single default amount or a long-press of the Lightning bolt for all your zapping.
|
||||
|
||||
‎
|
||||
##### Do Zaps Help Content Rank Higher?
|
||||
### Do zaps help content rank higher?
|
||||
|
||||
Yes. The ranking of an item is affected by:
|
||||
|
||||
- the amount a stacker zaps a post or comment
|
||||
- the trust of the stacker making the zap
|
||||
- the amount stackers zapped a post or comment
|
||||
- the trust of the zappers
|
||||
- the time elapsed since the creation of the item
|
||||
|
||||
Zapping an item with more sats amplifies your trust, giving you more influence on an item's ranking. However, the relationship between sats contributed and a stacker's influence on item ranking is not linear, it's logarithmic.
|
||||
Zapping an item with more sats gives you more influence on an item's ranking. However, the relationship between sats contributed and a stacker's influence on item ranking is not linear, it's logarithmic: the effect a stacker's zap has on an item's ranking is `trust*log10(total zap amount)`. This basically means that 10 sats equal 1 vote, 100 sats 2, 1000 sats 3, and so on ... all values in between and above 0 are valid as well.
|
||||
|
||||
The effect a stacker's zap has on an item's ranking is `trust*log10(total zap amount)` where 10 sats = 1 vote, 100 sats = 2, 1000 sats = 3, and so on ... all values in between are valid as well.
|
||||
To make this feature sybil-resistant, SN takes 30% of zaps and re-distributes them to territory founders and the SN community as part of the daily rewards.
|
||||
|
||||
To make this feature sybil resistant, SN takes 30% of zaps and re-distributes them to territory founders and the SN community as part of the daily rewards.
|
||||
### Why should I zap?
|
||||
|
||||
‎
|
||||
##### Why Should I Zap Posts on Stacker News?
|
||||
|
||||
There are a few reasons to zap posts on Stacker News:
|
||||
There are four reasons to zap posts on Stacker News:
|
||||
|
||||
1. To influence the ranking of content on the site
|
||||
|
||||
@ -479,208 +311,112 @@ Sending someone a like or an upvote incurs no cost to you, and therefore these m
|
||||
|
||||
3. To earn trust for identifying good content
|
||||
|
||||
On Stacker News, new stackers start with zero trust and either earn trust by zapping good content or lose trust by zapping bad content.
|
||||
On Stacker News, new stackers start with zero trust and either earn trust by zapping good content or lose trust by zapping bad content. Good and bad content is determined by overall consensus based on zaps.
|
||||
|
||||
‎
|
||||
##### Can I Donate Sats to Stacker News?
|
||||
4. To earn sats from the daily rewards pool
|
||||
|
||||
You can earn sats from the daily rewards pool by zapping content that ends up performing well. The amount you receive is proportional to your trust, the amount of sats you zapped and how early you zapped compared to others.
|
||||
|
||||
### Can I donate sats to Stacker News?
|
||||
|
||||
Yes. Every day, Stacker News distributes the revenue it collects from job listings, posting fees, boosts, and donations back to the stackers who made the best contributions on a given day.
|
||||
|
||||
To donate sats directly to the Stacker News rewards pool, or to view the rewards that will be distributed to stackers tomorrow, [click here](https://stacker.news/rewards).
|
||||
To donate sats directly to the Stacker News rewards pool, or to view the rewards that will be distributed to stackers tomorrow, click [here](/rewards).
|
||||
|
||||
### Someone zapped me 100 sats but I only received 70 sats. Why?
|
||||
|
||||
-----
|
||||
SN takes 30% of zaps and re-distributes them to territory founders (21%) and the SN community as part of the daily rewards (9%).
|
||||
|
||||
## Job Board
|
||||
So this means if someone zaps your post or comment 100 sats, 70 sats go to you, 21 sats go to the territory founder and the remaining 9 sats are distributed as part of the daily rewards.
|
||||
|
||||
‎
|
||||
##### How Do I Post a Job on Stacker News?
|
||||
### Is there an equivalent to downvotes?
|
||||
|
||||
To post a job on Stacker News:
|
||||
Yes. If you see content that you think should not be on Stacker News, you can click the `...` next to the post or comment and select 'downzap'. You can then enter a custom amount to downzap the content.
|
||||
|
||||
1. Navigate to the ~jobs territory
|
||||
2. Click post
|
||||
|
||||
Fill out all the details of your job listing, including:
|
||||
|
||||
- Job title
|
||||
- Company name
|
||||
- Location
|
||||
- Description
|
||||
- Application URL or email
|
||||
|
||||
If you wish to promote your job, you can also set a budget for your job listing.
|
||||
|
||||
All promoted jobs are paid for on a sats per minute basis, though you can also see an expected monthly USD price when you set your budget.
|
||||
|
||||
Your budget determines how highly your job listing will rank against other promoted jobs on the Stacker News job board.
|
||||
|
||||
If you want to get more people viewing your job, consider raising your budget above the rate that other employers are paying for their listings.
|
||||
|
||||
If you choose not to promote your job, your listing will be shown in reverse-chronological order, and will be pushed down the job board as new listings appear on Stacker News.
|
||||
|
||||
‎
|
||||
##### How Are Job Listings Ranked on Stacker News?
|
||||
|
||||
Each job is listed in reverse-chronological order on Stacker News, with an option for employers to pay a promotion fee to maintain the ranking of their job listing over time.
|
||||
|
||||
For employers who choose to promote their jobs, the fee amount determines the ranking of a job. The more an employer is willing to pay to advertise their job, the higher their listing will rank.
|
||||
|
||||
If two jobs have identical fees, the first job that was posted will rank higher than the more recent one.
|
||||
|
||||
‎
|
||||
##### Where Do Job Posting Fees Go?
|
||||
|
||||
Stacker News earns revenue from job posting fees, as well as boosts, post and comment fees, and a fee on all zaps on the platform. All of that revenue is then paid back to stackers as daily rewards.
|
||||
|
||||
The sats from the daily rewards go to the stackers who contribute posts and comments each day.
|
||||
|
||||
-----
|
||||
|
||||
## Ranking & Influence on Stacker News
|
||||
|
||||
‎
|
||||
##### What Does The Lightning Bolt Button Do?
|
||||
|
||||
The lightning bolt button next to each post and comment is a tool for stackers to signal that they like what they see.
|
||||
|
||||
The big difference between the Stacker News lightning bolt and the "like" or "upvote" buttons you might find on other sites is that when you press the lightning bolt you're not only raising the ranking of that content, you're also zapping the stacker who created the content with your sats.
|
||||
|
||||
- A grey lightning bolt icon means you haven't zapped the post or comment yet
|
||||
- A colored lightning bolt icon means you have zapped the post or comment (the color changes depending on how much you zap, and you can zap as many times as you like)
|
||||
- If there is no lightning bolt next to a post or comment it means you created the content, and therefore can't zap it
|
||||
|
||||
‎
|
||||
##### How Does Stacker News Rank Content?
|
||||
|
||||
Stacker News uses sats alongside a Web of Trust to rank content and deter Sybil attacks.
|
||||
|
||||
As [explained here](https://stacker.news/items/349#do-zaps-help-content-rank-higher), stackers can send zaps to each other by clicking the lightning bolt next to a post or comment. The zap amounts are one factor that helps determine which content ranks highest on the site, and are weighted by how much the stacker sending the zap is trusted.
|
||||
|
||||
The Stacker News ranking algorithm works as follows:
|
||||
|
||||
- The number of stackers who have zapped an item
|
||||
- Multiplied by the product of the trust score of each stacker and the log value of sats zapped
|
||||
- Divided by a power of the time since a story was submitted
|
||||
- Plus the boost divided by a larger power (relative to un-boosted ranking) of the time since a story was submitted
|
||||
|
||||
The comments made within a post are ranked the same way as top-level Stacker News posts.
|
||||
|
||||
‎
|
||||
##### How Does The Stacker News Web of Trust Work?
|
||||
|
||||
Each stacker has a trust score on Stacker News. New accounts start without any trust, and over time stackers can earn trust by zapping good content, and lose trust by zapping bad content.
|
||||
|
||||
The only consideration that factors into a stacker’s trust level is whether or not they are zapping good content. The zap amount does not impact a stacker's trust.
|
||||
|
||||
In addition, stackers do not lose or gain trust for making posts or comments. Instead, the post and comment fees are the mechanism that incentivizes stackers to only make high quality posts and comments.
|
||||
|
||||
A stacker’s trust is an important factor in determining how much influence their zaps have on the ranking of content, and how much they earn from the daily sat reward pool paid to zappers as [explained here](https://stacker.news/items/349#why-should-i-zap-posts-on-stacker-news).
|
||||
|
||||
‎
|
||||
##### How Do I Flag Content I Don't Like?
|
||||
|
||||
If you see content you don't like, you can click the `...` next to the post or comment to flag it. This is a form of negative feedback that helps Stacker News decide which content should be visible on the site.
|
||||
|
||||
It costs 1 sat to flag content, and doing so doesn't affect your trust or the trust of the stacker who posted the content. Instead, it simply lowers the visibility of the specific item for all stackers on Tenderfoot mode.
|
||||
Downzapping content is a form of negative feedback that reduces the visibility of the specific item for all stackers who don't have Wild West mode enabled in their [settings](/settings). If Wild West mode is not enabled, you are in Tenderfoot mode which is the default mode.
|
||||
|
||||
If an item gets flagged by stackers with enough combined trust, it is outlawed and hidden from view for stackers on Tenderfoot mode. If you wish to see this flagged content without any modifications, you can enable Wild West mode in your settings.
|
||||
|
||||
‎
|
||||
##### What is Tenderfoot Mode?
|
||||
### What are turbo zaps?
|
||||
|
||||
Tenderfoot mode hides or lowers the visibility of flagged content on Stacker News. This is the default setting for all stackers.
|
||||
Turbo zaps are an opt-in feature. They are a convenient way to modify your zap amounts on the go, rather than relying on a single default amount or a long-press of the lightning bolt for all your zapping.
|
||||
|
||||
‎
|
||||
##### What is Wild West Mode?
|
||||
When enabled in your [settings](/settings), every lightning bolt click on a specific post or comment raises your **total zap amount** to the next 10x of your default zap amount. For example, if your default zap amount is 1 sat:
|
||||
|
||||
Wild West mode allows you to see all content on Stacker News, including content that has been flagged by stackers.
|
||||
- your first click: zap 1 sat for a total of 1 sat
|
||||
- your second click: zap additional 9 sats for a total of 10 sats
|
||||
- your third click: zap additional 90 sats for a total of 100 sats
|
||||
- your fourth click: zap additional 900 sats for a total of 1000 sats
|
||||
- and so on ...
|
||||
|
||||
This unfiltered view doesn't modify the visibility of items on Stacker News based on negative feedback from stackers.
|
||||
Turbo zaps only escalate your zapping amount when you repeatedly click on the lightning bolt of a specific post or comment. Zapping a new post or comment will once again start at your default zap amount, and escalate by 10x with every additional click.
|
||||
|
||||
You can enable Wild West mode in your settings panel.
|
||||
### What are random zaps?
|
||||
|
||||
‎
|
||||
##### What is sats filter?
|
||||
Instead of zapping the same default amount on each press, the 'random zaps' [setting](/settings) allows you to select a range from which the zap amount will be randomly chosen on each press. This leads to greater privacy and a more fun zapping experience.
|
||||
|
||||
Sats filter allows you to choose how many sats have been "invested" in a post or content for you to see it. "Invested" sats are the sum of posting costs, zapped sats, and boost.
|
||||
### I accidentally zapped too much! Can I prevent this from happening again?
|
||||
|
||||
If you'd like to see all content regardless of investment, set your sats filter to 0.
|
||||
Yes, you can enable zap undos in your [settings](/settings). Once enabled, any zap above your specified threshold will make the bolt pulse for 5 seconds. Clicking the bolt again while it's pulsing will undo the zap.
|
||||
|
||||
-----
|
||||
_In case you wonder how we can undo zaps when lightning transactions are final: it's because we don't actually "undo zaps". We simply delay the zap to give you a chance to abort it. We make them look like undos for UX reasons._
|
||||
|
||||
## Notification Settings
|
||||
---
|
||||
|
||||
‎
|
||||
##### Where Are My Stacker News Notifications?
|
||||
## Web of Trust
|
||||
|
||||
To see your notifications, click the bell icon in the top-right corner of the screen. A red dot next to the bell icon indicates a new notification.
|
||||
Stacker News relies on a [Web of Trust](https://en.wikipedia.org/wiki/Web_of_trust) between stackers to drive ranking and daily rewards.
|
||||
|
||||
To change your notification settings:
|
||||
### How does the Web of Trust work?
|
||||
|
||||
1. Click your username
|
||||
2. Click [settings](/settings)
|
||||
3. Update your preferences from the ‘Notify me when…’ section
|
||||
There are two trust scores: trust scores between stackers and global trust scores (trust scores assigned to individual stackers).
|
||||
|
||||
‎
|
||||
##### How Do I Create A Bio on Stacker News?
|
||||
New accounts start without any trust and over time earn trust from other stackers by zapping content before them.
|
||||
|
||||
To fill out your bio:
|
||||
The only consideration that factors into a stacker's trust level is whether or not they are zapping good content. Zap amounts do not impact stackers' trust scores.
|
||||
|
||||
1. Click your username
|
||||
2. Click profile
|
||||
3. Click edit bio
|
||||
In addition, stackers do not lose or gain trust for making posts or comments. Instead, the post and comment fees are the mechanism that incentivizes stackers to only make high quality posts and comments.
|
||||
|
||||
‎
|
||||
##### How Do I View My Past Stacker News Transactions?
|
||||
A stacker’s trust is an important factor in determining how much influence their zaps have on the ranking of content, and how much they earn from the daily sat reward pool paid to zappers.
|
||||
|
||||
To view your transaction history:
|
||||
The trust scores are computed daily based on the zapping activity of stackers.
|
||||
|
||||
1. Click your [wallet balance](/wallet) next to your username
|
||||
2. Click wallet history
|
||||
Your global trust score is basically how much stackers trust you on average.
|
||||
|
||||
-----
|
||||
### Can I see my trust scores?
|
||||
|
||||
No. All trust scores are private. We might make them public in the future but for now, they are kept private to protect the integrity of ranking and rewards.
|
||||
|
||||
### Is my feed personalized?
|
||||
|
||||
Yes. If someone zapped a post or comment before you, your trust in them to show you content you like increases. This means content that these early zappers zapped will rank higher in your feed.
|
||||
|
||||
A common misconception is that we show you more content of the stackers you zapped. This is not the case. Think of it this way: if you and a friend like the same band, you would ask that friend to show you more similar music and not ask the band to never change their music and produce more of it.
|
||||
|
||||
---
|
||||
|
||||
## Other FAQs
|
||||
|
||||
‎
|
||||
##### How Does The Stacker News Referral Program Work?
|
||||
### Where should I submit feature requests?
|
||||
|
||||
For every new stacker you refer, you'll receive:
|
||||
Ideally on Github [here](https://github.com/stackernews/stacker.news/issues/new?template=feature_request.yml). The more background you give on your feature request the better. The hardest part of developing a feature is understanding the problem it solves, all the things that can wrong, etc.
|
||||
|
||||
- 2.1% of all the sats they earn for their content
|
||||
- 21% of all the sats they spend on boosts or job listings
|
||||
### Will Stacker News pay for contributions?
|
||||
|
||||
Any Stacker News link can be turned into a referral link by appending /r/<your nym>, e.g. `/r/k00b` to the link. This means you can earn sats for sharing Stacker News links on any website, newsletter, video, social media post, or podcast.
|
||||
Yes, we pay sats for PRs. See the section about contributing in our [README](https://github.com/stackernews/stacker.news?tab=readme-ov-file#contributing) for the details.
|
||||
|
||||
Some examples of referral links using @k00b as an example include:
|
||||
### Where should I submit bug reports?
|
||||
|
||||
`https://stacker.news/r/k00b`
|
||||
`https://stacker.news/items/109473/r/k00b`
|
||||
`https://stacker.news/top/posts/r/k00b?when=week`
|
||||
You can submit bug reports on Github [here](https://github.com/stackernews/stacker.news/issues/new?template=bug_report.yml).
|
||||
|
||||
To make referring stackers easy, every post also has a link sharing button in the upper right corner. If you are logged in, copying the link will automatically add your referral code to it.
|
||||
If you found a security or privacy issue, please consider a [responsible disclosure](#how-to-do-a-responsible-disclosure).
|
||||
|
||||
For logged in stackers, there is a [dashboard](https://stacker.news/referrals/month) to track your referrals and how much you're earning from them. It's available in the dropdown in the navbar.
|
||||
### How to do a responsible disclosure?
|
||||
|
||||
The money paid out to those who refer new stackers comes out of SN's revenue. The referee doesn't pay anything extra, the referrer just gets extra sats as a reward from SN.
|
||||
If you found a vulnerability on Stacker News, we would greatly appreciate it if you report it on Github [here](https://github.com/stackernews/stacker.news/security/advisories/new).
|
||||
|
||||
‎
|
||||
##### Where Should I Submit Feature Requests?
|
||||
Ideally on the git repo https://github.com/stackernews/stacker.news/issues. The more background you give on your feature request the better. The hardest part of developing a feature is understanding the problem it solves, all the things that can wrong, etc.
|
||||
You can also contact us via security@stacker.news or [t.me/k00bideh](https://t.me/k00bideh). Our PGP key can be found [here](/pgp.txt).
|
||||
|
||||
‎
|
||||
##### Will Stacker News Pay For Contributions?
|
||||
Yes, we pay sats for PRs. Sats will be proportional to the impact of the PR. If there's something you'd like to work on, suggest how much you'd do it for on the issue. If there's something you'd like to work on that isn't already an issue, whether its a bug fix or a new feature, create one.
|
||||
### Where can I ask more questions?
|
||||
|
||||
‎
|
||||
##### Where Should I Submit Bug Reports?
|
||||
Bug reports can be submitted on our git repo: https://github.com/stackernews/stacker.news/issues.
|
||||
|
||||
‎
|
||||
##### Responsible Disclosure
|
||||
If you find a vulnerability on Stacker News, we would greatly appreciate it if you contact us via hello@stacker.news or [t.me/k00bideh](https://t.me/k00bideh).
|
||||
|
||||
‎
|
||||
##### Where Can I Ask More Questions?
|
||||
Reply to this FAQ. It's like any other post on the site.
|
@ -18,6 +18,7 @@ export const COMMENT_FIELDS = gql`
|
||||
position
|
||||
parentId
|
||||
createdAt
|
||||
invoicePaidAt
|
||||
deletedAt
|
||||
text
|
||||
user {
|
||||
@ -45,6 +46,7 @@ export const COMMENT_FIELDS = gql`
|
||||
mine
|
||||
otsHash
|
||||
ncomments
|
||||
nDirectComments
|
||||
imgproxyUrls
|
||||
rel
|
||||
apiKey
|
||||
@ -65,6 +67,7 @@ export const COMMENTS_ITEM_EXT_FIELDS = gql`
|
||||
id
|
||||
title
|
||||
bounty
|
||||
ncomments
|
||||
bountyPaidTo
|
||||
subName
|
||||
sub {
|
||||
@ -88,19 +91,23 @@ export const COMMENTS = gql`
|
||||
fragment CommentsRecursive on Item {
|
||||
...CommentFields
|
||||
comments {
|
||||
...CommentFields
|
||||
comments {
|
||||
...CommentFields
|
||||
comments {
|
||||
...CommentFields
|
||||
comments {
|
||||
...CommentFields
|
||||
comments {
|
||||
...CommentFields
|
||||
comments {
|
||||
...CommentFields
|
||||
comments {
|
||||
...CommentFields
|
||||
comments {
|
||||
...CommentFields
|
||||
comments {
|
||||
comments {
|
||||
...CommentFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ export const ITEM_FIELDS = gql`
|
||||
id
|
||||
parentId
|
||||
createdAt
|
||||
invoicePaidAt
|
||||
deletedAt
|
||||
title
|
||||
url
|
||||
@ -34,6 +35,7 @@ export const ITEM_FIELDS = gql`
|
||||
meMuteSub
|
||||
meSubscription
|
||||
nsfw
|
||||
replyCost
|
||||
}
|
||||
otsHash
|
||||
position
|
||||
@ -56,6 +58,7 @@ export const ITEM_FIELDS = gql`
|
||||
freebie
|
||||
bio
|
||||
ncomments
|
||||
nDirectComments
|
||||
commentSats
|
||||
commentCredits
|
||||
lastCommentAt
|
||||
@ -93,6 +96,7 @@ export const ITEM_FULL_FIELDS = gql`
|
||||
bountyPaidTo
|
||||
subName
|
||||
mine
|
||||
ncomments
|
||||
user {
|
||||
id
|
||||
name
|
||||
@ -165,13 +169,16 @@ export const ITEM_FULL = gql`
|
||||
${ITEM_FULL_FIELDS}
|
||||
${POLL_FIELDS}
|
||||
${COMMENTS}
|
||||
query Item($id: ID!, $sort: String) {
|
||||
query Item($id: ID!, $sort: String, $cursor: String) {
|
||||
item(id: $id) {
|
||||
...ItemFullFields
|
||||
prior
|
||||
...PollFields
|
||||
comments(sort: $sort) {
|
||||
...CommentsRecursive
|
||||
comments(sort: $sort, cursor: $cursor) {
|
||||
cursor
|
||||
comments {
|
||||
...CommentsRecursive
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
@ -112,6 +112,18 @@ export const NOTIFICATIONS = gql`
|
||||
... on Referral {
|
||||
id
|
||||
sortTime
|
||||
source {
|
||||
__typename
|
||||
... on Item {
|
||||
...ItemFullFields
|
||||
}
|
||||
... on Sub {
|
||||
...SubFields
|
||||
}
|
||||
... on User {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
... on Reply {
|
||||
id
|
||||
|
@ -25,7 +25,9 @@ const ITEM_PAID_ACTION_FIELDS = gql`
|
||||
reminderScheduledAt
|
||||
...CommentFields
|
||||
comments {
|
||||
...CommentsRecursive
|
||||
comments {
|
||||
...CommentsRecursive
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
@ -262,10 +264,10 @@ export const UPDATE_COMMENT = gql`
|
||||
export const UPSERT_SUB = gql`
|
||||
${PAID_ACTION}
|
||||
mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!,
|
||||
$postTypes: [String!]!, $billingType: String!,
|
||||
$replyCost: Int!, $postTypes: [String!]!, $billingType: String!,
|
||||
$billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) {
|
||||
upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost,
|
||||
postTypes: $postTypes, billingType: $billingType,
|
||||
replyCost: $replyCost, postTypes: $postTypes, billingType: $billingType,
|
||||
billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) {
|
||||
result {
|
||||
name
|
||||
@ -277,10 +279,10 @@ export const UPSERT_SUB = gql`
|
||||
export const UNARCHIVE_TERRITORY = gql`
|
||||
${PAID_ACTION}
|
||||
mutation unarchiveTerritory($name: String!, $desc: String, $baseCost: Int!,
|
||||
$postTypes: [String!]!, $billingType: String!,
|
||||
$replyCost: Int!, $postTypes: [String!]!, $billingType: String!,
|
||||
$billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) {
|
||||
unarchiveTerritory(name: $name, desc: $desc, baseCost: $baseCost,
|
||||
postTypes: $postTypes, billingType: $billingType,
|
||||
replyCost: $replyCost, postTypes: $postTypes, billingType: $billingType,
|
||||
billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) {
|
||||
result {
|
||||
name
|
||||
|
@ -25,6 +25,7 @@ export const SUB_FIELDS = gql`
|
||||
billedLastAt
|
||||
billPaidUntil
|
||||
baseCost
|
||||
replyCost
|
||||
userId
|
||||
desc
|
||||
status
|
||||
|
@ -297,17 +297,20 @@ export const USER_FULL = gql`
|
||||
${USER_FIELDS}
|
||||
${ITEM_FULL_FIELDS}
|
||||
${COMMENTS}
|
||||
query User($name: String!, $sort: String) {
|
||||
query User($name: String!, $sort: String, $cursor: String) {
|
||||
user(name: $name) {
|
||||
...UserFields
|
||||
bio {
|
||||
...ItemFullFields
|
||||
comments(sort: $sort) {
|
||||
...CommentsRecursive
|
||||
comments(sort: $sort, cursor: $cursor) {
|
||||
cursor
|
||||
comments {
|
||||
...CommentsRecursive
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
}
|
||||
}`
|
||||
|
||||
export const USER = gql`
|
||||
${USER_FIELDS}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { ApolloClient, InMemoryCache, HttpLink, makeVar, split } from '@apollo/client'
|
||||
import { ApolloClient, InMemoryCache, HttpLink, makeVar, split, from } from '@apollo/client'
|
||||
import { BatchHttpLink } from '@apollo/client/link/batch-http'
|
||||
import { decodeCursor, LIMIT } from './cursor'
|
||||
import { SSR } from './constants'
|
||||
|
||||
import { COMMENTS_LIMIT, SSR } from './constants'
|
||||
import { RetryLink } from '@apollo/client/link/retry'
|
||||
function isFirstPage (cursor, existingThings, limit = LIMIT) {
|
||||
if (cursor) {
|
||||
const decursor = decodeCursor(cursor)
|
||||
@ -28,13 +28,30 @@ export default function getApolloClient () {
|
||||
|
||||
export const meAnonSats = {}
|
||||
|
||||
const retryLink = new RetryLink({
|
||||
delay: {
|
||||
initial: 300,
|
||||
max: 30000,
|
||||
jitter: true
|
||||
},
|
||||
attempts: {
|
||||
max: Infinity,
|
||||
retryIf: (error, _operation) => {
|
||||
return !!error
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function getClient (uri) {
|
||||
const link = split(
|
||||
// batch zaps if wallet is enabled so they can be executed serially in a single request
|
||||
operation => operation.operationName === 'act' && operation.variables.act === 'TIP' && operation.getContext().batch,
|
||||
new BatchHttpLink({ uri, batchInterval: 1000, batchDebounce: true, batchMax: 0, batchKey: op => op.variables.id }),
|
||||
new HttpLink({ uri })
|
||||
)
|
||||
const link = from([
|
||||
retryLink,
|
||||
split(
|
||||
// batch zaps if wallet is enabled so they can be executed serially in a single request
|
||||
operation => operation.operationName === 'act' && operation.variables.act === 'TIP' && operation.getContext().batch,
|
||||
new BatchHttpLink({ uri, batchInterval: 1000, batchDebounce: true, batchMax: 0, batchKey: op => op.variables.id }),
|
||||
new HttpLink({ uri })
|
||||
)
|
||||
])
|
||||
|
||||
return new ApolloClient({
|
||||
link,
|
||||
@ -201,12 +218,6 @@ function getClient (uri) {
|
||||
}
|
||||
}
|
||||
},
|
||||
comments: {
|
||||
keyArgs: ['id', 'sort'],
|
||||
merge (existing, incoming) {
|
||||
return incoming
|
||||
}
|
||||
},
|
||||
related: {
|
||||
keyArgs: ['id', 'title', 'minMatch', 'limit'],
|
||||
merge (existing, incoming, { args }) {
|
||||
@ -277,6 +288,19 @@ function getClient (uri) {
|
||||
},
|
||||
Item: {
|
||||
fields: {
|
||||
comments: {
|
||||
keyArgs: ['sort'],
|
||||
merge (existing, incoming) {
|
||||
if (isFirstPage(incoming.cursor, existing?.comments, COMMENTS_LIMIT)) {
|
||||
return incoming
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: incoming.cursor,
|
||||
comments: [...(existing?.comments || []), ...incoming.comments]
|
||||
}
|
||||
}
|
||||
},
|
||||
meAnonSats: {
|
||||
read (existingAmount, { readField }) {
|
||||
if (SSR) return null
|
||||
|
@ -40,7 +40,10 @@ 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
|
||||
export const MAX_TITLE_LENGTH = 80
|
||||
export const MIN_TITLE_LENGTH = 5
|
||||
export const MAX_POST_TEXT_LENGTH = 100000 // 100k
|
||||
@ -194,3 +197,5 @@ export const ZAP_UNDO_DELAY_MS = 5_000
|
||||
|
||||
export const WALLET_SEND_PAYMENT_TIMEOUT_MS = 150_000
|
||||
export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 45_000
|
||||
|
||||
export const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
|
||||
|
@ -5,6 +5,7 @@ export function decodeCursor (cursor) {
|
||||
return { offset: 0, time: new Date() }
|
||||
} else {
|
||||
const res = JSON.parse(Buffer.from(cursor, 'base64'))
|
||||
res.offset = Number(res.offset)
|
||||
res.time = new Date(res.time)
|
||||
return res
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { COMMENT_DEPTH_LIMIT, OLD_ITEM_DAYS } from './constants'
|
||||
import { COMMENT_DEPTH_LIMIT, FULL_COMMENTS_THRESHOLD, OLD_ITEM_DAYS } from './constants'
|
||||
import { datePivot } from './time'
|
||||
|
||||
export const defaultCommentSort = (pinned, bio, createdAt) => {
|
||||
@ -105,7 +105,11 @@ export const deleteReminders = async ({ id, userId, models }) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const commentSubTreeRootId = (item) => {
|
||||
export const commentSubTreeRootId = (item, root) => {
|
||||
if (item.root?.ncomments > FULL_COMMENTS_THRESHOLD || root?.ncomments > FULL_COMMENTS_THRESHOLD) {
|
||||
return item.id
|
||||
}
|
||||
|
||||
const path = item.path.split('.')
|
||||
return path.slice(-(COMMENT_DEPTH_LIMIT - 1))[0]
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { SKIP, visit } from 'unist-util-visit'
|
||||
import { parseEmbedUrl, parseInternalLinks } from './url'
|
||||
import { parseEmbedUrl, parseInternalLinks, isMisleadingLink } from './url'
|
||||
import { slug } from 'github-slugger'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
|
||||
@ -16,6 +16,11 @@ export default function rehypeSN (options = {}) {
|
||||
return function transformer (tree) {
|
||||
try {
|
||||
visit(tree, (node, index, parent) => {
|
||||
if (parent?.tagName === 'code') {
|
||||
// don't process code blocks
|
||||
return
|
||||
}
|
||||
|
||||
// Handle inline code property
|
||||
if (node.tagName === 'code') {
|
||||
node.properties.inline = !(parent && parent.tagName === 'pre')
|
||||
@ -250,22 +255,6 @@ export default function rehypeSN (options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function isMisleadingLink (text, href) {
|
||||
let misleading = false
|
||||
|
||||
if (/^\s*(\w+\.)+\w+/.test(text)) {
|
||||
try {
|
||||
const hrefUrl = new URL(href)
|
||||
|
||||
if (new URL(hrefUrl.protocol + text).origin !== hrefUrl.origin) {
|
||||
misleading = true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return misleading
|
||||
}
|
||||
|
||||
function replaceNostrId (value, id) {
|
||||
return {
|
||||
type: 'element',
|
||||
|
23
lib/url.js
23
lib/url.js
@ -241,6 +241,29 @@ export function decodeProxyUrl (imgproxyUrl) {
|
||||
return originalUrl
|
||||
}
|
||||
|
||||
export function isMisleadingLink (text, href) {
|
||||
let misleading = false
|
||||
|
||||
try {
|
||||
const hrefUrl = new URL(href)
|
||||
|
||||
try {
|
||||
const textUrl = new URL(text)
|
||||
if (textUrl.origin !== hrefUrl.origin) {
|
||||
misleading = true
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (/^\s*([\w-]+\.)+\w+/.test(text)) {
|
||||
if (new URL(hrefUrl.protocol + text).origin !== hrefUrl.origin) {
|
||||
misleading = true
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return misleading
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
export const URL_REGEXP = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
import { parseInternalLinks } from './url.js'
|
||||
import { parseInternalLinks, isMisleadingLink } from './url.js'
|
||||
|
||||
const cases = [
|
||||
const internalLinkCases = [
|
||||
['https://stacker.news/items/123', '#123'],
|
||||
['https://stacker.news/items/123/related', '#123/related'],
|
||||
// invalid links should not be parsed so user can spot error
|
||||
@ -20,7 +20,7 @@ const cases = [
|
||||
]
|
||||
|
||||
describe('internal links', () => {
|
||||
test.each(cases)(
|
||||
test.each(internalLinkCases)(
|
||||
'parses %p as %p',
|
||||
(href, expected) => {
|
||||
process.env.NEXT_PUBLIC_URL = 'https://stacker.news'
|
||||
@ -29,3 +29,30 @@ describe('internal links', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const misleadingLinkCases = [
|
||||
// if text is the same as the link, it's not misleading
|
||||
['https://stacker.news/items/1234', 'https://stacker.news/items/1234', false],
|
||||
// same origin is not misleading
|
||||
['https://stacker.news/items/1235', 'https://stacker.news/items/1234', false],
|
||||
['www.google.com', 'https://www.google.com', false],
|
||||
['stacker.news', 'https://stacker.news', false],
|
||||
// if text is obviously not a link, it's not misleading
|
||||
['innocent text', 'https://stacker.news/items/1234', false],
|
||||
['innocenttext', 'https://stacker.news/items/1234', false],
|
||||
// if text might be a link to a different origin, it's misleading
|
||||
['innocent.text', 'https://stacker.news/items/1234', true],
|
||||
['https://google.com', 'https://bing.com', true],
|
||||
['www.google.com', 'https://bing.com', true],
|
||||
['s-tacker.news', 'https://snacker.news', true]
|
||||
]
|
||||
|
||||
describe('misleading links', () => {
|
||||
test.each(misleadingLinkCases)(
|
||||
'identifies [%p](%p) as misleading: %p',
|
||||
(text, href, expected) => {
|
||||
const actual = isMisleadingLink(text, href)
|
||||
expect(actual).toBe(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
@ -317,6 +317,9 @@ export function territorySchema (args) {
|
||||
baseCost: intValidator
|
||||
.min(1, 'must be at least 1')
|
||||
.max(100000, 'must be at most 100k'),
|
||||
replyCost: intValidator
|
||||
.min(1, 'must be at least 1')
|
||||
.max(100000, 'must be at most 100k'),
|
||||
postTypes: array().of(string().oneOf(POST_TYPES)).min(1, 'must support at least one post type'),
|
||||
billingType: string().required('required').oneOf(TERRITORY_BILLING_TYPES, 'required'),
|
||||
nsfw: boolean()
|
||||
@ -382,6 +385,10 @@ export const emailSchema = object({
|
||||
email: string().email('email is no good').required('required')
|
||||
})
|
||||
|
||||
export const emailTokenSchema = object({
|
||||
token: string().required('required').trim().matches(/^[0-9a-z]{6}$/i, 'must be 6 alphanumeric characters')
|
||||
})
|
||||
|
||||
export const urlSchema = object({
|
||||
url: string().url().required('required')
|
||||
})
|
||||
|
102
lib/webPush.js
102
lib/webPush.js
@ -4,6 +4,7 @@ import { COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants'
|
||||
import { msatsToSats, numWithUnits } from './format'
|
||||
import models from '@/api/models'
|
||||
import { isMuted } from '@/lib/user'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
const webPushEnabled = process.env.NODE_ENV === 'production' ||
|
||||
(process.env.VAPID_MAILTO && process.env.NEXT_PUBLIC_VAPID_PUBKEY && process.env.VAPID_PRIVKEY)
|
||||
@ -36,7 +37,7 @@ const createPayload = (notification) => {
|
||||
const createUserFilter = (tag) => {
|
||||
// filter users by notification settings
|
||||
const tagMap = {
|
||||
REPLY: 'noteAllDescendants',
|
||||
THREAD: 'noteAllDescendants',
|
||||
MENTION: 'noteMentions',
|
||||
ITEM_MENTION: 'noteItemMentions',
|
||||
TIP: 'noteItemSats',
|
||||
@ -123,21 +124,36 @@ export async function replyToSubscription (subscriptionId, notification) {
|
||||
export const notifyUserSubscribers = async ({ models, item }) => {
|
||||
try {
|
||||
const isPost = !!item.title
|
||||
const userSubsExcludingMutes = await models.$queryRawUnsafe(`
|
||||
SELECT "UserSubscription"."followerId", "UserSubscription"."followeeId", users.name as "followeeName"
|
||||
FROM "UserSubscription"
|
||||
INNER JOIN users ON users.id = "UserSubscription"."followeeId"
|
||||
WHERE "followeeId" = $1 AND ${isPost ? '"postsSubscribedAt"' : '"commentsSubscribedAt"'} IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = "UserSubscription"."followerId" AND "Mute"."mutedId" = $1)
|
||||
-- ignore subscription if user was already notified of item as a reply
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Reply"
|
||||
INNER JOIN users follower ON follower.id = "UserSubscription"."followerId"
|
||||
WHERE "Reply"."itemId" = $2
|
||||
AND "Reply"."ancestorUserId" = follower.id
|
||||
AND follower."noteAllDescendants"
|
||||
)
|
||||
`, Number(item.userId), Number(item.id))
|
||||
|
||||
const userSubsExcludingMutes = await models.$queryRaw`
|
||||
SELECT "UserSubscription"."followerId", "UserSubscription"."followeeId", users.name as "followeeName"
|
||||
FROM "UserSubscription"
|
||||
INNER JOIN users ON users.id = "UserSubscription"."followeeId"
|
||||
WHERE "followeeId" = ${Number(item.userId)}::INTEGER
|
||||
AND ${isPost ? Prisma.sql`"postsSubscribedAt"` : Prisma.sql`"commentsSubscribedAt"`} IS NOT NULL
|
||||
-- ignore muted users
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "Mute"
|
||||
WHERE "Mute"."muterId" = "UserSubscription"."followerId"
|
||||
AND "Mute"."mutedId" = ${Number(item.userId)}::INTEGER)
|
||||
-- ignore subscription if user was already notified of item as a reply
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Reply"
|
||||
INNER JOIN users follower ON follower.id = "UserSubscription"."followerId"
|
||||
WHERE "Reply"."itemId" = ${Number(item.id)}::INTEGER
|
||||
AND "Reply"."ancestorUserId" = follower.id
|
||||
AND follower."noteAllDescendants"
|
||||
)
|
||||
-- ignore subscription if user has posted to a territory the recipient is subscribed to
|
||||
${isPost
|
||||
? Prisma.sql`AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "SubSubscription"
|
||||
WHERE "SubSubscription"."userId" = "UserSubscription"."followerId"
|
||||
AND "SubSubscription"."subName" = ${item.subName}
|
||||
)`
|
||||
: Prisma.empty}`
|
||||
const subType = isPost ? 'POST' : 'COMMENT'
|
||||
const tag = `FOLLOW-${item.userId}-${subType}`
|
||||
await Promise.allSettled(userSubsExcludingMutes.map(({ followerId, followeeName }) => sendUserNotification(followerId, {
|
||||
@ -186,20 +202,60 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const notifyThreadSubscribers = async ({ models, item }) => {
|
||||
try {
|
||||
const author = await models.user.findUnique({ where: { id: item.userId } })
|
||||
|
||||
const subscribers = await models.$queryRaw`
|
||||
SELECT DISTINCT "ThreadSubscription"."userId" FROM "ThreadSubscription"
|
||||
JOIN users ON users.id = "ThreadSubscription"."userId"
|
||||
JOIN "Reply" r ON "ThreadSubscription"."itemId" = r."ancestorId"
|
||||
WHERE r."itemId" = ${item.id}
|
||||
-- don't send notifications for own items
|
||||
AND r."userId" <> "ThreadSubscription"."userId"
|
||||
-- send notifications for all levels?
|
||||
AND CASE WHEN users."noteAllDescendants" THEN TRUE ELSE r.level = 1 END
|
||||
-- muted?
|
||||
AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = users.id AND m."mutedId" = r."userId")
|
||||
-- already received notification as reply to self?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Item" i
|
||||
JOIN "Item" p ON p.path @> i.path
|
||||
WHERE i.id = ${item.parentId} AND p."userId" = "ThreadSubscription"."userId" AND users."noteAllDescendants"
|
||||
)`
|
||||
|
||||
await Promise.allSettled(subscribers.map(({ userId }) =>
|
||||
sendUserNotification(userId, {
|
||||
// we reuse the same payload as for user subscriptions because they use the same title+body we want to use here
|
||||
// so we should also merge them together (= same tag+data) to avoid confusion
|
||||
title: `@${author.name} replied to a post`,
|
||||
body: item.text,
|
||||
item,
|
||||
data: { followeeName: author.name, subType: 'COMMENT' },
|
||||
tag: `FOLLOW-${author.id}-COMMENT`
|
||||
})
|
||||
))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
export const notifyItemParents = async ({ models, item }) => {
|
||||
try {
|
||||
const user = await models.user.findUnique({ where: { id: item.userId } })
|
||||
const parents = await models.$queryRawUnsafe(
|
||||
'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2 ' +
|
||||
'SELECT DISTINCT p."userId", i."userId" = p."userId" as "isDirect" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2 ' +
|
||||
'AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = p."userId" AND m."mutedId" = $2)',
|
||||
Number(item.parentId), Number(user.id))
|
||||
Promise.allSettled(
|
||||
parents.map(({ userId }) => sendUserNotification(userId, {
|
||||
title: `@${user.name} replied to you`,
|
||||
body: item.text,
|
||||
item,
|
||||
tag: 'REPLY'
|
||||
}))
|
||||
parents.map(({ userId, isDirect }) => {
|
||||
return sendUserNotification(userId, {
|
||||
title: `@${user.name} ${isDirect ? 'replied to you' : 'replied to someone that replied to you'}`,
|
||||
body: item.text,
|
||||
item,
|
||||
tag: isDirect ? 'REPLY' : 'THREAD'
|
||||
})
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
@ -9,6 +9,26 @@ const territoryPattern = new URLPattern({ pathname: '/~:name([\\w_]+){/*}?' })
|
||||
const SN_REFERRER = 'sn_referrer'
|
||||
// we use this to hold /r/... referrers through the redirect
|
||||
const SN_REFERRER_NONCE = 'sn_referrer_nonce'
|
||||
// key for referred pages
|
||||
const SN_REFEREE_LANDING = 'sn_referee_landing'
|
||||
|
||||
function getContentReferrer (request, url) {
|
||||
if (itemPattern.test(url)) {
|
||||
let id = request.nextUrl.searchParams.get('commentId')
|
||||
if (!id) {
|
||||
({ id } = itemPattern.exec(url).pathname.groups)
|
||||
}
|
||||
return `item-${id}`
|
||||
}
|
||||
if (profilePattern.test(url)) {
|
||||
const { name } = profilePattern.exec(url).pathname.groups
|
||||
return `profile-${name}`
|
||||
}
|
||||
if (territoryPattern.test(url)) {
|
||||
const { name } = territoryPattern.exec(url).pathname.groups
|
||||
return `territory-${name}`
|
||||
}
|
||||
}
|
||||
|
||||
// we store the referrers in cookies for a future signup event
|
||||
// we pass the referrers in the request headers so we can use them in referral rewards for logged in stackers
|
||||
@ -25,6 +45,14 @@ function referrerMiddleware (request) {
|
||||
// referrers. Content referrers do not override explicit referrers because
|
||||
// explicit referees might click around before signing up.
|
||||
response.cookies.set(SN_REFERRER, referrer, { maxAge: 60 * 60 * 24 })
|
||||
|
||||
// we record the first page the user lands on and keep it for 24 hours
|
||||
// in addition to the explicit referrer, this allows us to tell the referrer
|
||||
// which share link the user clicked on
|
||||
const contentReferrer = getContentReferrer(request, url)
|
||||
if (contentReferrer) {
|
||||
response.cookies.set(SN_REFEREE_LANDING, contentReferrer, { maxAge: 60 * 60 * 24 })
|
||||
}
|
||||
// store the explicit referrer for one page load
|
||||
// this allows us to attribute both explicit and implicit referrers after the redirect
|
||||
// e.g. items/<num>/r/<referrer> links should attribute both the item op and the referrer
|
||||
@ -33,22 +61,9 @@ function referrerMiddleware (request) {
|
||||
return response
|
||||
}
|
||||
|
||||
let contentReferrer
|
||||
if (itemPattern.test(request.url)) {
|
||||
let id = request.nextUrl.searchParams.get('commentId')
|
||||
if (!id) {
|
||||
({ id } = itemPattern.exec(request.url).pathname.groups)
|
||||
}
|
||||
contentReferrer = `item-${id}`
|
||||
} else if (profilePattern.test(request.url)) {
|
||||
const { name } = profilePattern.exec(request.url).pathname.groups
|
||||
contentReferrer = `profile-${name}`
|
||||
} else if (territoryPattern.test(request.url)) {
|
||||
const { name } = territoryPattern.exec(request.url).pathname.groups
|
||||
contentReferrer = `territory-${name}`
|
||||
}
|
||||
const contentReferrer = getContentReferrer(request, request.url)
|
||||
|
||||
// pass the referrers to SSR in the request headers
|
||||
// pass the referrers to SSR in the request headers for one day referrer attribution
|
||||
const requestHeaders = new Headers(request.headers)
|
||||
const referrers = [request.cookies.get(SN_REFERRER_NONCE)?.value, contentReferrer].filter(Boolean)
|
||||
if (referrers.length) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import NextAuth from 'next-auth'
|
||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||
import GitHubProvider from 'next-auth/providers/github'
|
||||
@ -15,6 +15,7 @@ import { notifyReferral } from '@/lib/webPush'
|
||||
import { hashEmail } from '@/lib/crypto'
|
||||
import * as cookie from 'cookie'
|
||||
import { multiAuthMiddleware } from '@/pages/api/graphql'
|
||||
import { BECH32_CHARSET } from '@/lib/constants'
|
||||
|
||||
/**
|
||||
* Stores userIds in user table
|
||||
@ -39,20 +40,46 @@ function getEventCallbacks () {
|
||||
}
|
||||
}
|
||||
|
||||
async function getReferrerId (referrer) {
|
||||
async function getReferrerFromCookie (referrer) {
|
||||
let referrerId
|
||||
let type
|
||||
let typeId
|
||||
try {
|
||||
if (referrer.startsWith('item-')) {
|
||||
return (await prisma.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } }))?.userId
|
||||
const item = await prisma.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } })
|
||||
type = item?.parentId ? 'COMMENT' : 'POST'
|
||||
referrerId = item?.userId
|
||||
typeId = item?.id
|
||||
} else if (referrer.startsWith('profile-')) {
|
||||
return (await prisma.user.findUnique({ where: { name: referrer.slice(8) } }))?.id
|
||||
const user = await prisma.user.findUnique({ where: { name: referrer.slice(8) } })
|
||||
type = 'PROFILE'
|
||||
referrerId = user?.id
|
||||
typeId = user?.id
|
||||
} else if (referrer.startsWith('territory-')) {
|
||||
return (await prisma.sub.findUnique({ where: { name: referrer.slice(10) } }))?.userId
|
||||
type = 'TERRITORY'
|
||||
typeId = referrer.slice(10)
|
||||
const sub = await prisma.sub.findUnique({ where: { name: typeId } })
|
||||
referrerId = sub?.userId
|
||||
} else {
|
||||
return (await prisma.user.findUnique({ where: { name: referrer } }))?.id
|
||||
return {
|
||||
referrerId: (await prisma.user.findUnique({ where: { name: referrer } }))?.id
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('error getting referrer id', error)
|
||||
return
|
||||
}
|
||||
return { referrerId, type, typeId: String(typeId) }
|
||||
}
|
||||
|
||||
async function getReferrerData (referrer, landing) {
|
||||
const referrerData = await getReferrerFromCookie(referrer)
|
||||
if (landing) {
|
||||
const landingData = await getReferrerFromCookie(landing)
|
||||
// explicit referrer takes precedence over landing referrer
|
||||
return { ...landingData, ...referrerData }
|
||||
}
|
||||
return referrerData
|
||||
}
|
||||
|
||||
/** @returns {Partial<import('next-auth').CallbacksOptions>} */
|
||||
@ -76,10 +103,17 @@ function getCallbacks (req, res) {
|
||||
// isNewUser doesn't work for nostr/lightning auth because we create the user before nextauth can
|
||||
// this means users can update their referrer if they don't have one, which is fine
|
||||
if (req.cookies.sn_referrer && user?.id) {
|
||||
const referrerId = await getReferrerId(req.cookies.sn_referrer)
|
||||
if (referrerId && referrerId !== parseInt(user?.id)) {
|
||||
const { count } = await prisma.user.updateMany({ where: { id: user.id, referrerId: null }, data: { referrerId } })
|
||||
if (count > 0) notifyReferral(referrerId)
|
||||
const referrerData = await getReferrerData(req.cookies.sn_referrer, req.cookies.sn_referee_landing)
|
||||
if (referrerData?.referrerId && referrerData.referrerId !== parseInt(user?.id)) {
|
||||
// if user doesn't have a referrer, record it in the db
|
||||
const { count } = await prisma.user.updateMany({ where: { id: user.id, referrerId: null }, data: { referrerId: referrerData.referrerId } })
|
||||
if (count > 0) {
|
||||
// if user has an associated landing, record it in the db
|
||||
if (referrerData.type && referrerData.typeId) {
|
||||
await prisma.oneDayReferral.create({ data: { ...referrerData, refereeId: user.id, landing: true } })
|
||||
}
|
||||
notifyReferral(referrerData.referrerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -272,6 +306,8 @@ const getProviders = res => [
|
||||
EmailProvider({
|
||||
server: process.env.LOGIN_EMAIL_SERVER,
|
||||
from: process.env.LOGIN_EMAIL_FROM,
|
||||
maxAge: 5 * 60, // expires in 5 minutes
|
||||
generateVerificationToken: generateRandomString,
|
||||
sendVerificationRequest
|
||||
})
|
||||
]
|
||||
@ -321,6 +357,40 @@ export const getAuthOptions = (req, res) => ({
|
||||
user.email = email
|
||||
}
|
||||
return user
|
||||
},
|
||||
useVerificationToken: async ({ identifier, token }) => {
|
||||
// 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) { // if correct delete the token and continue
|
||||
await prisma.verificationToken.delete({
|
||||
where: { id: verificationRequest.id }
|
||||
})
|
||||
return verificationRequest
|
||||
}
|
||||
|
||||
await prisma.verificationToken.update({
|
||||
where: { id: verificationRequest.id },
|
||||
data: { attempts: { increment: 1 } }
|
||||
})
|
||||
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: { id: verificationRequest.id, attempts: { gte: 2 } }
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
},
|
||||
session: {
|
||||
@ -366,9 +436,22 @@ export default async (req, res) => {
|
||||
await NextAuth(req, res, getAuthOptions(req, res))
|
||||
}
|
||||
|
||||
function generateRandomString (length = 6, charset = BECH32_CHARSET) {
|
||||
const bytes = randomBytes(length)
|
||||
let result = ''
|
||||
|
||||
// Map each byte to a character in the charset
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += charset[bytes[i] % charset.length]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function sendVerificationRequest ({
|
||||
identifier: email,
|
||||
url,
|
||||
token,
|
||||
provider
|
||||
}) {
|
||||
let user = await prisma.user.findUnique({
|
||||
@ -397,8 +480,8 @@ async function sendVerificationRequest ({
|
||||
to: email,
|
||||
from,
|
||||
subject: `login to ${site}`,
|
||||
text: text({ url, site, email }),
|
||||
html: user ? html({ url, site, email }) : newUserHtml({ url, site, email })
|
||||
text: text({ url, token, site, email }),
|
||||
html: user ? html({ url, token, site, email }) : newUserHtml({ url, token, site, email })
|
||||
},
|
||||
(error) => {
|
||||
if (error) {
|
||||
@ -411,7 +494,7 @@ async function sendVerificationRequest ({
|
||||
}
|
||||
|
||||
// Email HTML body
|
||||
const html = ({ url, site, email }) => {
|
||||
const html = ({ url, token, site, email }) => {
|
||||
// Insert invisible space into domains and email address to prevent both the
|
||||
// email address and the domain from being turned into a hyperlink by email
|
||||
// clients like Outlook and Apple mail, as this is confusing because it seems
|
||||
@ -423,8 +506,6 @@ const html = ({ url, site, email }) => {
|
||||
const backgroundColor = '#f5f5f5'
|
||||
const textColor = '#212529'
|
||||
const mainBackgroundColor = '#ffffff'
|
||||
const buttonBackgroundColor = '#FADA5E'
|
||||
const buttonTextColor = '#212529'
|
||||
|
||||
// Uses tables for layout and inline CSS due to email client limitations
|
||||
return `
|
||||
@ -439,26 +520,32 @@ const html = ({ url, site, email }) => {
|
||||
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||
login as <strong>${escapedEmail}</strong>
|
||||
login with <strong>${escapedEmail}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 20px 0;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; text-decoration: none;border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBackgroundColor}; display: inline-block; font-weight: bold;">login</a></td>
|
||||
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||
copy this magic code
|
||||
</td>
|
||||
<tr><td height="10px"></td></tr>
|
||||
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 36px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||
<strong>${token}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||
Or copy and paste this link: <a href="#" style="text-decoration:none; color:${textColor}">${url}</a>
|
||||
<td align="center" style="font-size:0px;padding:0px 20px;word-break:break-word;">
|
||||
<div style="font-family:Arial, sans-serif;font-size:11px;line-height:22px;text-align:center;color:#55575d;">Expires in 5 minutes</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 10px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||
If you did not request this email you can safely ignore it.
|
||||
<td align="center" style="font-size:0px;padding:0px 20px;word-break:break-word;">
|
||||
<div style="font-family:Arial, sans-serif;font-size:11px;line-height:22px;text-align:center;color:#55575d;">If you did not request this email you can safely ignore it.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -467,28 +554,21 @@ const html = ({ url, site, email }) => {
|
||||
}
|
||||
|
||||
// Email text body –fallback for email clients that don't render HTML
|
||||
const text = ({ url, site }) => `Sign in to ${site}\n${url}\n\n`
|
||||
const text = ({ url, token, site }) => `Sign in to ${site}\ncopy this code: ${token}\n\n\nExpires in 5 minutes`
|
||||
|
||||
const newUserHtml = ({ url, site, email }) => {
|
||||
const newUserHtml = ({ url, token, site, email }) => {
|
||||
const escapedEmail = `${email.replace(/\./g, '​.')}`
|
||||
|
||||
const replaceCb = (path) => {
|
||||
const urlObj = new URL(url)
|
||||
urlObj.searchParams.set('callbackUrl', path)
|
||||
return urlObj.href
|
||||
}
|
||||
|
||||
const dailyUrl = replaceCb('/daily')
|
||||
const guideUrl = replaceCb('/guide')
|
||||
const faqUrl = replaceCb('/faq')
|
||||
const topUrl = replaceCb('/top/stackers/forever')
|
||||
const postUrl = replaceCb('/post')
|
||||
const dailyUrl = new URL('/daily', process.env.NEXT_PUBLIC_URL).href
|
||||
const guideUrl = new URL('/guide', process.env.NEXT_PUBLIC_URL).href
|
||||
const faqUrl = new URL('/faq', process.env.NEXT_PUBLIC_URL).href
|
||||
const topUrl = new URL('/top/stackers/forever', process.env.NEXT_PUBLIC_URL).href
|
||||
const postUrl = new URL('/post', process.env.NEXT_PUBLIC_URL).href
|
||||
|
||||
// Some simple styling options
|
||||
const backgroundColor = '#f5f5f5'
|
||||
const textColor = '#212529'
|
||||
const mainBackgroundColor = '#ffffff'
|
||||
const buttonBackgroundColor = '#FADA5E'
|
||||
|
||||
return `
|
||||
<!doctype html>
|
||||
@ -606,7 +686,7 @@ const newUserHtml = ({ url, site, email }) => {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:16px;line-height:22px;text-align:left;color:#000000;">If you know how Stacker News works, click the login button below.</div>
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:16px;line-height:22px;text-align:left;color:#000000;">If you know how Stacker News works, copy the magic code below.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@ -635,25 +715,27 @@ const newUserHtml = ({ url, site, email }) => {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:18px;line-height:1;text-align:center;color:#000000;">login as <b>${escapedEmail}</b></div>
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:18px;line-height:1;text-align:center;color:#000000;">login with <b>${escapedEmail}</b></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:30px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<td align="center" style="padding: 20px 0;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" bgcolor="${buttonBackgroundColor}" role="presentation" style="border:none;border-radius:5px;cursor:auto;mso-padding-alt:15px 40px;background:${buttonBackgroundColor};" valign="middle">
|
||||
<a href="${url}" style="display:inline-block;background:${buttonBackgroundColor};color:${textColor};font-family:Helvetica, Arial, sans-serif;font-size:22px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:15px 40px;mso-padding-alt:0px;border-radius:5px;" target="_blank">
|
||||
<mj-text align="center" font-family="Helvetica, Arial, sans-serif" font-size="20px"><b font-family="Helvetica, Arial, sans-serif">login</b></mj-text>
|
||||
</a>
|
||||
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||
copy this magic code
|
||||
</td>
|
||||
<tr><td height="10px"></td></tr>
|
||||
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 36px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||
<strong>${token}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:24px;text-align:center;color:#000000;">Or copy and paste this link: <a href="#" style="text-decoration:none; color:#787878">${url}</a></div>
|
||||
<td align="center" style="font-size:0px;padding:0px 20px;word-break:break-word;">
|
||||
<div style="font-family:Arial, sans-serif;font-size:11px;line-height:22px;text-align:center;color:#55575d;">Expires in 5 minutes</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@ -707,7 +789,7 @@ const newUserHtml = ({ url, site, email }) => {
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Zap,<br /> Stacker News</div>
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Yeehaw,<br /> Stacker News</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@ -731,7 +813,7 @@ const newUserHtml = ({ url, site, email }) => {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0px 25px 0px 25px;word-break:break-word;">
|
||||
<div style="font-family:Arial, sans-serif;font-size:14px;line-height:28px;text-align:center;color:#55575d;">P.S. Stacker News loves you!</div>
|
||||
<div style="font-family:Arial, sans-serif;font-size:14px;line-height:28px;text-align:center;color:#55575d;">P.S. We're thrilled you're joinin' the posse!</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@ -1,7 +1,6 @@
|
||||
import Image from 'react-bootstrap/Image'
|
||||
import { StaticLayout } from '@/components/layout'
|
||||
import styles from '@/styles/error.module.css'
|
||||
import LightningIcon from '@/svgs/bolt.svg'
|
||||
import { useRouter } from 'next/router'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
|
||||
@ -27,20 +26,15 @@ export default function AuthError ({ error }) {
|
||||
return (
|
||||
<StaticLayout>
|
||||
<Image className='rounded-1 shadow-sm' width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/double.gif`} fluid />
|
||||
<h2 className='pt-4'>This magic link has expired.</h2>
|
||||
<h4 className='text-muted pt-2'>Get another by logging in.</h4>
|
||||
<h2 className='pt-4'>Incorrect magic code</h2>
|
||||
<Button
|
||||
className='align-items-center my-3'
|
||||
style={{ borderWidth: '2px' }}
|
||||
id='login'
|
||||
onClick={() => router.push('/login')}
|
||||
onClick={() => router.back()}
|
||||
size='lg'
|
||||
>
|
||||
<LightningIcon
|
||||
width={24}
|
||||
height={24}
|
||||
className='me-2'
|
||||
/>login
|
||||
try again
|
||||
</Button>
|
||||
</StaticLayout>
|
||||
)
|
||||
|
@ -1,11 +1,32 @@
|
||||
import Image from 'react-bootstrap/Image'
|
||||
import { StaticLayout } from '@/components/layout'
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Form, SubmitButton, MultiInput } from '@/components/form'
|
||||
import { emailTokenSchema } from '@/lib/validate'
|
||||
|
||||
// force SSR to include CSP nonces
|
||||
export const getServerSideProps = getGetServerSideProps({ query: null })
|
||||
|
||||
export default function Email () {
|
||||
const router = useRouter()
|
||||
const [callback, setCallback] = useState(null) // callback.email, callback.callbackUrl
|
||||
|
||||
useEffect(() => {
|
||||
setCallback(JSON.parse(window.sessionStorage.getItem('callback')))
|
||||
}, [])
|
||||
|
||||
// build and push the final callback URL
|
||||
const pushCallback = useCallback((token) => {
|
||||
const params = new URLSearchParams()
|
||||
if (callback.callbackUrl) params.set('callbackUrl', callback.callbackUrl)
|
||||
params.set('token', token)
|
||||
params.set('email', callback.email)
|
||||
const url = `/api/auth/callback/email?${params.toString()}`
|
||||
router.push(url)
|
||||
}, [callback, router])
|
||||
|
||||
return (
|
||||
<StaticLayout>
|
||||
<div className='p-4 text-center'>
|
||||
@ -14,8 +35,36 @@ export default function Email () {
|
||||
<Image className='rounded-1 shadow-sm' width='640' height='302' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/cowboy-saloon.gif`} fluid />
|
||||
</video>
|
||||
<h2 className='pt-4'>Check your email</h2>
|
||||
<h4 className='text-muted pt-2'>A sign in link has been sent to your email address</h4>
|
||||
<h4 className='text-muted pt-2 pb-4'>a magic code has been sent to {callback ? callback.email : 'your email address'}</h4>
|
||||
<MagicCodeForm onSubmit={(token) => pushCallback(token)} disabled={!callback} />
|
||||
</div>
|
||||
</StaticLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export const MagicCodeForm = ({ onSubmit, disabled }) => {
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
token: ''
|
||||
}}
|
||||
schema={emailTokenSchema}
|
||||
onSubmit={(values) => {
|
||||
onSubmit(values.token.toLowerCase()) // token is displayed in uppercase but we need to check it in lowercase
|
||||
}}
|
||||
>
|
||||
<MultiInput
|
||||
length={6}
|
||||
charLength={1}
|
||||
name='token'
|
||||
required
|
||||
autoFocus
|
||||
groupClassName='d-flex flex-column justify-content-center gap-2'
|
||||
inputType='text'
|
||||
hideError // hide error message on every input, allow custom error message
|
||||
disabled={disabled} // disable the form if no callback is provided
|
||||
/>
|
||||
<SubmitButton variant='primary' className='px-4' disabled={disabled}>login</SubmitButton>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
@ -14,15 +14,19 @@ export const getServerSideProps = getGetServerSideProps({
|
||||
export default function Item ({ ssrData }) {
|
||||
const router = useRouter()
|
||||
|
||||
const { data } = useQuery(ITEM_FULL, { variables: { ...router.query } })
|
||||
const { data, fetchMore } = useQuery(ITEM_FULL, { variables: { ...router.query } })
|
||||
if (!data && !ssrData) return <PageLoading />
|
||||
|
||||
const { item } = data || ssrData
|
||||
const sub = item.subName || item.root?.subName
|
||||
|
||||
const fetchMoreComments = async () => {
|
||||
await fetchMore({ variables: { ...router.query, cursor: item.comments.cursor } })
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout sub={sub} item={item}>
|
||||
<ItemFull item={item} />
|
||||
<ItemFull item={item} fetchMoreComments={fetchMoreComments} />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
@ -138,7 +138,7 @@ export default function Rewards ({ ssrData }) {
|
||||
<div className='fw-bold text-muted pb-2'>
|
||||
top boost this month
|
||||
</div>
|
||||
<ListItem item={ad} />
|
||||
<ListItem item={ad} ad />
|
||||
</div>}
|
||||
<Row className='pb-3'>
|
||||
<Col lg={leaderboard?.users && 5}>
|
||||
|
@ -858,7 +858,7 @@ function AuthMethods ({ methods, apiKeyEnabled }) {
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: <div key={provider} className='mt-2'><EmailLinkForm /></div>
|
||||
: <div key={provider} className='mt-2'><EmailLinkForm callbackUrl='/settings' /></div>
|
||||
} else if (provider === 'lightning') {
|
||||
return (
|
||||
<QRLinkButton
|
||||
@ -910,6 +910,7 @@ export function EmailLinkForm ({ callbackUrl }) {
|
||||
// then call signIn
|
||||
const { data } = await linkUnverifiedEmail({ variables: { email } })
|
||||
if (data.linkUnverifiedEmail) {
|
||||
window.sessionStorage.setItem('callback', JSON.stringify({ email, callbackUrl }))
|
||||
signIn('email', { email, callbackUrl })
|
||||
}
|
||||
}}
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Sub" ADD COLUMN "replyCost" INTEGER NOT NULL DEFAULT 1;
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "verification_requests" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0;
|
147
prisma/migrations/20250118010433_comment_pages/migration.sql
Normal file
147
prisma/migrations/20250118010433_comment_pages/migration.sql
Normal file
@ -0,0 +1,147 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Item" ADD COLUMN "nDirectComments" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Update nDirectComments
|
||||
UPDATE "Item"
|
||||
SET "nDirectComments" = "DirectComments"."nDirectComments"
|
||||
FROM (
|
||||
SELECT "Item"."parentId" AS "id", COUNT(*) AS "nDirectComments"
|
||||
FROM "Item"
|
||||
WHERE "Item"."parentId" IS NOT NULL
|
||||
GROUP BY "Item"."parentId"
|
||||
) AS "DirectComments"
|
||||
WHERE "Item"."id" = "DirectComments"."id";
|
||||
|
||||
-- add limit and offset
|
||||
CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me_limited(
|
||||
_item_id int, _global_seed int, _me_id int, _limit int, _offset int, _grandchild_limit int,
|
||||
_level int, _where text, _order_by text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
|
||||
$$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
BEGIN
|
||||
IF _level < 1 THEN
|
||||
RETURN '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
|
||||
|| 'WITH RECURSIVE base AS ( '
|
||||
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn, '
|
||||
|| ' GREATEST(g.tf_hot_score, l.tf_hot_score) AS personal_hot_score, '
|
||||
|| ' GREATEST(g.tf_top_score, l.tf_top_score) AS personal_top_score '
|
||||
|| ' FROM "Item" '
|
||||
|| ' LEFT JOIN zap_rank_personal_view g ON g."viewerId" = $2 AND g.id = "Item".id '
|
||||
|| ' LEFT JOIN zap_rank_personal_view l ON l."viewerId" = $3 AND l.id = g.id '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by || ' '
|
||||
|| ' LIMIT $4 '
|
||||
|| ' OFFSET $5) '
|
||||
|| ' UNION ALL '
|
||||
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') as rn, '
|
||||
|| ' GREATEST(g.tf_hot_score, l.tf_hot_score) AS personal_hot_score, '
|
||||
|| ' GREATEST(g.tf_top_score, l.tf_top_score) AS personal_top_score '
|
||||
|| ' FROM "Item" '
|
||||
|| ' JOIN base b ON "Item"."parentId" = b.id '
|
||||
|| ' LEFT JOIN zap_rank_personal_view g ON g."viewerId" = $2 AND g.id = "Item".id '
|
||||
|| ' LEFT JOIN zap_rank_personal_view l ON l."viewerId" = $3 AND l.id = g.id '
|
||||
|| ' WHERE b.level < $7 AND (b.level = 1 OR b.rn <= $6)) '
|
||||
|| ') '
|
||||
|| 'SELECT "Item".*, '
|
||||
|| ' "Item".created_at at time zone ''UTC'' AS "createdAt", '
|
||||
|| ' "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|
||||
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
|
||||
|| ' to_jsonb(users.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
|
||||
|| ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", '
|
||||
|| ' COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", '
|
||||
|| ' COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
|
||||
|| ' COALESCE("ItemAct"."meMcredits", 0) AS "meMcredits", '
|
||||
|| ' COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", '
|
||||
|| ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", '
|
||||
|| ' "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription" '
|
||||
|| 'FROM base "Item" '
|
||||
|| 'JOIN users ON users.id = "Item"."userId" '
|
||||
|| ' LEFT JOIN "Mute" ON "Mute"."muterId" = $3 AND "Mute"."mutedId" = "Item"."userId" '
|
||||
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $3 AND "Bookmark"."itemId" = "Item".id '
|
||||
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $3 AND "ThreadSubscription"."itemId" = "Item".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", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMcredits", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMsats", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMcredits", '
|
||||
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
|
||||
|| ' FROM "ItemAct" '
|
||||
|| ' LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" '
|
||||
|| ' LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" '
|
||||
|| ' WHERE "ItemAct"."userId" = $3 '
|
||||
|| ' AND "ItemAct"."itemId" = "Item".id '
|
||||
|| ' GROUP BY "ItemAct"."itemId" '
|
||||
|| ') "ItemAct" ON true '
|
||||
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $6 - "Item".level + 2) ' || _where || ' '
|
||||
USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
|
||||
EXECUTE ''
|
||||
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|
||||
|| 'FROM ( '
|
||||
|| ' SELECT "Item".*, item_comments_zaprank_with_me_limited("Item".id, $2, $3, $4, $5, $6, $7 - 1, $8, $9) AS comments '
|
||||
|| ' FROM t_item "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by
|
||||
|| ' ) sub'
|
||||
INTO result USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
|
||||
RETURN result;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- add limit and offset
|
||||
CREATE OR REPLACE FUNCTION item_comments_limited(
|
||||
_item_id int, _limit int, _offset int, _grandchild_limit int,
|
||||
_level int, _where text, _order_by text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
|
||||
$$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
BEGIN
|
||||
IF _level < 1 THEN
|
||||
RETURN '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
|
||||
|| 'WITH RECURSIVE base AS ( '
|
||||
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
|
||||
|| ' FROM "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by || ' '
|
||||
|| ' LIMIT $2 '
|
||||
|| ' OFFSET $3) '
|
||||
|| ' UNION ALL '
|
||||
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') '
|
||||
|| ' FROM "Item" '
|
||||
|| ' JOIN base b ON "Item"."parentId" = b.id '
|
||||
|| ' WHERE b.level < $5 AND (b.level = 1 OR b.rn <= $4)) '
|
||||
|| ') '
|
||||
|| 'SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|
||||
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
|
||||
|| ' to_jsonb(users.*) as user '
|
||||
|| 'FROM base "Item" '
|
||||
|| 'JOIN users ON users.id = "Item"."userId" '
|
||||
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $4) ' || _where
|
||||
USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
|
||||
|
||||
EXECUTE ''
|
||||
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|
||||
|| 'FROM ( '
|
||||
|| ' SELECT "Item".*, item_comments_limited("Item".id, $2, $3, $4, $5 - 1, $6, $7) AS comments '
|
||||
|| ' FROM t_item "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by
|
||||
|| ' ) sub'
|
||||
INTO result USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
RETURN result;
|
||||
END
|
||||
$$;
|
@ -0,0 +1,3 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Item_invoicePaidAt_idx" ON "Item"("invoicePaidAt");
|
||||
CREATE INDEX "Item_paid_created_idx" ON "Item" (COALESCE("invoicePaidAt", created_at) DESC);
|
@ -0,0 +1,49 @@
|
||||
-- add limit and offset
|
||||
CREATE OR REPLACE FUNCTION item_comments_limited(
|
||||
_item_id int, _limit int, _offset int, _grandchild_limit int,
|
||||
_level int, _where text, _order_by text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
|
||||
$$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
BEGIN
|
||||
IF _level < 1 THEN
|
||||
RETURN '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
|
||||
|| 'WITH RECURSIVE base AS ( '
|
||||
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
|
||||
|| ' FROM "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by || ' '
|
||||
|| ' LIMIT $2 '
|
||||
|| ' OFFSET $3) '
|
||||
|| ' UNION ALL '
|
||||
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') '
|
||||
|| ' FROM "Item" '
|
||||
|| ' JOIN base b ON "Item"."parentId" = b.id '
|
||||
|| ' WHERE b.level < $5 AND (b.level = 1 OR b.rn <= $4)) '
|
||||
|| ') '
|
||||
|| 'SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|
||||
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
|
||||
|| ' to_jsonb(users.*) as user '
|
||||
|| 'FROM base "Item" '
|
||||
|| 'JOIN users ON users.id = "Item"."userId" '
|
||||
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $4 - "Item".level + 2) ' || _where
|
||||
USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
|
||||
|
||||
EXECUTE ''
|
||||
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|
||||
|| 'FROM ( '
|
||||
|| ' SELECT "Item".*, item_comments_limited("Item".id, $2, $3, $4, $5 - 1, $6, $7) AS comments '
|
||||
|| ' FROM t_item "Item" '
|
||||
|| ' WHERE "Item"."parentId" = $1 '
|
||||
|| _order_by
|
||||
|| ' ) sub'
|
||||
INTO result USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||
RETURN result;
|
||||
END
|
||||
$$;
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OneDayReferral" ADD COLUMN "landing" BOOLEAN NOT NULL DEFAULT false;
|
@ -176,6 +176,7 @@ model OneDayReferral {
|
||||
referee User @relation("OneDayReferral_referrees", fields: [refereeId], references: [id], onDelete: Cascade)
|
||||
type OneDayReferralType
|
||||
typeId String
|
||||
landing Boolean @default(false)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([referrerId])
|
||||
@ -528,6 +529,7 @@ model Item {
|
||||
lastCommentAt DateTime?
|
||||
lastZapAt DateTime?
|
||||
ncomments Int @default(0)
|
||||
nDirectComments Int @default(0)
|
||||
msats BigInt @default(0)
|
||||
mcredits BigInt @default(0)
|
||||
cost Int @default(0)
|
||||
@ -598,6 +600,7 @@ model Item {
|
||||
@@index([cost])
|
||||
@@index([url])
|
||||
@@index([boost])
|
||||
@@index([invoicePaidAt])
|
||||
}
|
||||
|
||||
// we use this to denormalize a user's aggregated interactions (zaps) with an item
|
||||
@ -735,6 +738,7 @@ model Sub {
|
||||
rankingType RankingType
|
||||
allowFreebies Boolean @default(true)
|
||||
baseCost Int @default(1)
|
||||
replyCost Int @default(1)
|
||||
rewardsPct Int @default(50)
|
||||
desc String?
|
||||
status Status @default(ACTIVE)
|
||||
@ -1086,6 +1090,7 @@ model VerificationToken {
|
||||
identifier String
|
||||
token String @unique(map: "verification_requests.token_unique")
|
||||
expires DateTime
|
||||
attempts Int @default(0)
|
||||
|
||||
@@unique([identifier, token])
|
||||
@@map("verification_requests")
|
||||
|
197
scripts/welcome.js
Executable file
197
scripts/welcome.js
Executable file
@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
function usage () {
|
||||
console.log('Usage: scripts/welcome.js <fetch-after> [--prod]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let args = process.argv.slice(2)
|
||||
|
||||
const useProd = args.indexOf('--prod') !== -1
|
||||
const SN_API_URL = useProd ? 'https://stacker.news' : 'http://localhost:3000'
|
||||
args = args.filter(arg => arg !== '--prod')
|
||||
console.log('> url:', SN_API_URL)
|
||||
|
||||
// this is the item id of the last bio that was included in the previous post of the series
|
||||
const FETCH_AFTER = args[0]
|
||||
console.log('> fetch-after:', FETCH_AFTER)
|
||||
if (!FETCH_AFTER) {
|
||||
usage()
|
||||
}
|
||||
|
||||
const SN_API_KEY = process.env.SN_API_KEY
|
||||
if (!SN_API_KEY) {
|
||||
console.log('SN_API_KEY must be set in environment')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function gql (query, variables = {}) {
|
||||
const response = await fetch(`${SN_API_URL}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': SN_API_KEY },
|
||||
body: JSON.stringify({ query, variables })
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`request failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
if (json.errors) {
|
||||
throw new Error(json.errors[0].message)
|
||||
}
|
||||
|
||||
return json.data
|
||||
}
|
||||
|
||||
async function assertSettings () {
|
||||
const { me } = await gql(`
|
||||
query me {
|
||||
me {
|
||||
id
|
||||
name
|
||||
privates {
|
||||
wildWestMode
|
||||
satsFilter
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
console.log(`> logged in as @${me.name}`)
|
||||
|
||||
if (!me.privates.wildWestMode) {
|
||||
throw new Error('wild west mode must be enabled')
|
||||
}
|
||||
|
||||
if (me.privates.satsFilter !== 0) {
|
||||
throw new Error('sats filter must be set to 0')
|
||||
}
|
||||
}
|
||||
|
||||
function fetchRecentBios () {
|
||||
// fetch all recent bios. we assume here there won't be more than 21
|
||||
// since the last bio we already included in a post as defined by FETCH_AFTER.
|
||||
return gql(
|
||||
`query NewBios {
|
||||
items(sort: "recent", type: "bios", limit: 21) {
|
||||
items {
|
||||
id
|
||||
title
|
||||
createdAt
|
||||
user {
|
||||
name
|
||||
since
|
||||
nitems
|
||||
optional {
|
||||
stacked
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
)
|
||||
}
|
||||
|
||||
function filterBios (bios) {
|
||||
const newBios = bios.filter(b => b.id > FETCH_AFTER)
|
||||
if (newBios.length === bios.length) {
|
||||
throw new Error('last bio not found. increase limit')
|
||||
}
|
||||
return newBios
|
||||
}
|
||||
|
||||
async function populate (bios) {
|
||||
return await Promise.all(
|
||||
bios.map(
|
||||
async bio => {
|
||||
bio.user.since = await fetchItem(bio.user.since)
|
||||
bio.user.items = await fetchUserItems(bio.user.name)
|
||||
bio.user.credits = sumBy(bio.user.items, 'credits')
|
||||
bio.user.sats = sumBy(bio.user.items, 'sats') - bio.user.credits
|
||||
if (bio.user.sats > 0 || bio.user.credits > 0) {
|
||||
bio.user.satstandard = bio.user.sats / (bio.user.sats + bio.user.credits)
|
||||
} else {
|
||||
bio.user.satstandard = 0
|
||||
}
|
||||
return bio
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function printTable (bios) {
|
||||
console.log('| nym | bio (stacking since) | items | sats/ccs stacked | sat standard |')
|
||||
console.log('| --- | -------------------- | ----- | ---------------- | ------------ |')
|
||||
|
||||
for (const bio of bios) {
|
||||
const { user } = bio
|
||||
|
||||
const bioCreatedAt = formatDate(bio.createdAt)
|
||||
let col2 = dateLink(bio)
|
||||
if (Number(bio.id) !== user.since.id) {
|
||||
const sinceCreatedAt = formatDate(user.since.createdAt)
|
||||
// stacking since might not be the same item as the bio
|
||||
// but it can still have been created on the same day
|
||||
if (bioCreatedAt !== sinceCreatedAt) {
|
||||
col2 += ` (${dateLink(user.since)})`
|
||||
}
|
||||
}
|
||||
console.log(`| @${user.name} | ${col2} | ${user.nitems} | ${user.sats}/${user.credits} | ${user.satstandard.toFixed(2)} |`)
|
||||
}
|
||||
|
||||
console.log(`${bios.length} rows`)
|
||||
|
||||
return bios
|
||||
}
|
||||
|
||||
function formatDate (date) {
|
||||
return new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function sumBy (arr, key) {
|
||||
return arr.reduce((acc, item) => acc + item[key], 0)
|
||||
}
|
||||
|
||||
function itemLink (id) {
|
||||
return `https://stacker.news/items/${id}`
|
||||
}
|
||||
|
||||
function dateLink (item) {
|
||||
return `[${formatDate(item.createdAt)}](${itemLink(item.id)})`
|
||||
}
|
||||
|
||||
async function fetchItem (id) {
|
||||
const data = await gql(`
|
||||
query Item($id: ID!) {
|
||||
item(id: $id) {
|
||||
id
|
||||
createdAt
|
||||
}
|
||||
}`, { id }
|
||||
)
|
||||
return data.item
|
||||
}
|
||||
|
||||
async function fetchUserItems (name) {
|
||||
const data = await gql(`
|
||||
query UserItems($name: String!) {
|
||||
items(sort: "user", name: $name) {
|
||||
items {
|
||||
id
|
||||
createdAt
|
||||
sats
|
||||
credits
|
||||
}
|
||||
}
|
||||
}`, { name }
|
||||
)
|
||||
return data.items.items
|
||||
}
|
||||
|
||||
assertSettings()
|
||||
.then(fetchRecentBios)
|
||||
.then(data => filterBios(data.items.items))
|
||||
.then(populate)
|
||||
.then(printTable)
|
||||
.catch(console.error)
|
@ -985,6 +985,17 @@ div[contenteditable]:focus,
|
||||
}
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.react-datepicker-wrapper {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
|
@ -59,7 +59,7 @@ export function onPush (sw) {
|
||||
|
||||
// iOS requirement: wait for all promises to resolve before showing the notification
|
||||
event.waitUntil(Promise.all(promises).then(() => {
|
||||
sw.registration.showNotification(payload.title, payload.options)
|
||||
return sw.registration.showNotification(payload.title, payload.options)
|
||||
}))
|
||||
}
|
||||
}
|
||||
@ -91,7 +91,7 @@ const mergeNotification = (event, sw, payload, currentNotifications, tag, nid) =
|
||||
// merge notifications into single notification payload
|
||||
// ---
|
||||
// tags that need to know the amount of notifications with same tag for merging
|
||||
const AMOUNT_TAGS = ['REPLY', 'MENTION', 'ITEM_MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST']
|
||||
const AMOUNT_TAGS = ['REPLY', 'THREAD', 'MENTION', 'ITEM_MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST']
|
||||
// tags that need to know the sum of sats of notifications with same tag for merging
|
||||
const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL']
|
||||
// this should reflect the amount of notifications that were already merged before
|
||||
@ -116,6 +116,8 @@ const mergeNotification = (event, sw, payload, currentNotifications, tag, nid) =
|
||||
if (AMOUNT_TAGS.includes(compareTag)) {
|
||||
if (compareTag === 'REPLY') {
|
||||
title = `you have ${amount} new replies`
|
||||
} else if (compareTag === 'THREAD') {
|
||||
title = `you have ${amount} new follow-up replies`
|
||||
} else if (compareTag === 'MENTION') {
|
||||
title = `you were mentioned ${amount} times`
|
||||
} else if (compareTag === 'ITEM_MENTION') {
|
||||
|
@ -21,7 +21,12 @@ export const fields = [
|
||||
name: 'rune',
|
||||
label: 'invoice only rune',
|
||||
help: {
|
||||
text: 'We only accept runes that *only* allow `method=invoice`.\nRun this to generate one:\n\n```lightning-cli createrune restrictions=\'["method=invoice"]\'```'
|
||||
text: 'We only accept runes that *only* allow `method=invoice`.\n\n' +
|
||||
'Run this if you are on v23.08 to generate one:\n\n' +
|
||||
'```lightning-cli createrune restrictions=\'["method=invoice"]\'```\n\n' +
|
||||
'Or this if you are on v24.11 or later:\n\n' +
|
||||
'```lightning-cli createrune restrictions=\'[["method=invoice"]]\'```\n\n' +
|
||||
'[see `createrune` documentation](https://docs.corelightning.org/reference/lightning-createrune#restriction-format)'
|
||||
},
|
||||
type: 'text',
|
||||
placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==',
|
||||
|
@ -66,8 +66,6 @@ export async function createInvoice (userId, { msats, description, descriptionHa
|
||||
if (BigInt(msats) - BigInt(bolt11.mtokens) >= 1000n) {
|
||||
throw new Error('invoice invalid: amount too small')
|
||||
}
|
||||
|
||||
logger.warn('wallet does not support msats')
|
||||
}
|
||||
|
||||
return { invoice, wallet, logger }
|
||||
|
@ -4,8 +4,8 @@ import { toBigInt, toPositiveBigInt, toPositiveNumber } from '@/lib/format'
|
||||
|
||||
const MIN_OUTGOING_MSATS = BigInt(700) // the minimum msats we'll allow for the outgoing invoice
|
||||
const MAX_OUTGOING_MSATS = BigInt(700_000_000) // the maximum msats we'll allow for the outgoing invoice
|
||||
const MAX_EXPIRATION_INCOMING_MSECS = 900_000 // the maximum expiration time we'll allow for the incoming invoice
|
||||
const INCOMING_EXPIRATION_BUFFER_MSECS = 300_000 // the buffer enforce for the incoming invoice expiration
|
||||
const MAX_EXPIRATION_INCOMING_MSECS = 600_000 // the maximum expiration time we'll allow for the incoming invoice
|
||||
const INCOMING_EXPIRATION_BUFFER_MSECS = 120_000 // the buffer enforce for the incoming invoice expiration
|
||||
const MAX_OUTGOING_CLTV_DELTA = 1000 // the maximum cltv delta we'll allow for the outgoing invoice
|
||||
export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice
|
||||
const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request
|
||||
|
@ -74,6 +74,7 @@ export async function earn ({ name }) {
|
||||
FROM earners
|
||||
LEFT JOIN "OneDayReferral" ON "OneDayReferral"."refereeId" = earners."userId"
|
||||
WHERE "OneDayReferral".created_at >= date_trunc('day', now() AT TIME ZONE 'America/Chicago' - interval '1 day')
|
||||
AND "OneDayReferral".landing IS NOT TRUE
|
||||
GROUP BY earners."userId", earners."foreverReferrerId", earners.proportion, earners.rank
|
||||
ORDER BY rank ASC`
|
||||
|
||||
@ -189,7 +190,7 @@ function earnStmts (data, { models }) {
|
||||
})]
|
||||
}
|
||||
|
||||
const DAILY_STIMULUS_SATS = 75_000
|
||||
const DAILY_STIMULUS_SATS = 50_000
|
||||
export async function earnRefill ({ models, lnd }) {
|
||||
return await performPaidAction('DONATE',
|
||||
{ sats: DAILY_STIMULUS_SATS },
|
||||
|
@ -117,6 +117,11 @@ export async function indexItem ({ data: { id, updatedAt }, apollo, models }) {
|
||||
}`
|
||||
})
|
||||
|
||||
if (!item) {
|
||||
console.log('item not found', id)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. index it with external version based on updatedAt
|
||||
await _indexItem(item, { models, updatedAt })
|
||||
}
|
||||
|
@ -31,7 +31,9 @@ export async function payWeeklyPostBounty ({ data: { id }, models, apollo, lnd }
|
||||
bounty
|
||||
bountyPaidTo
|
||||
comments(sort: "top") {
|
||||
id
|
||||
comments {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
@ -44,7 +46,7 @@ export async function payWeeklyPostBounty ({ data: { id }, models, apollo, lnd }
|
||||
throw new Error('Bounty already paid')
|
||||
}
|
||||
|
||||
const winner = item.comments[0]
|
||||
const winner = item.comments.comments[0]
|
||||
|
||||
if (!winner) {
|
||||
throw new Error('No winner')
|
||||
|
Loading…
x
Reference in New Issue
Block a user