Compare commits

...

55 Commits

Author SHA1 Message Date
Edward Kung
15bd1c3fc5
Fix the check for misleading links (#1901)
* Fix the check for misleading links

* replace tabs with spaces

* remove trailing spaces

* move isMisleadingLinks to lib/url.js and create unit tests

* Add comments to test cases

* URLs can contain hyphens

---------

Co-authored-by: ekzyis <ek@stacker.news>
2025-02-14 09:43:08 -06:00
ekzyis
77781e07ed
Don't parse content in code blocks (#1899) 2025-02-13 11:46:53 -06:00
ekzyis
3cdf5c9451
Fix comment not outlined again (#1902) 2025-02-13 11:46:35 -06:00
k00b
a4cce7afed only record landing of referree if they don't have referrer 2025-02-12 10:10:25 -06:00
soxa
1afadbdf3b
enhance: referral notifications with source (#1862)
* wip: referral notification shows source of referral

* simpler approach for source info gathering

* fix territory representation; fix fragment field

* cleanup; fix UI

* better margin approach

* hotfix: null check

* add support for comments

* use Union to represent ReferralSource; clarify with switch statements

* cleanup: compact switch statement on Referral resolver

* wip use refereeLanding

* add comments; cleanup

* hotfix: backwards compatibility for Earnings calculation

* small copy and semantics changes

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-02-11 20:02:04 -06:00
k00b
bdd87e7d39 add retry link back 2025-02-11 16:29:37 -06:00
soxa
e6081ebef3
fix: THREAD notification type for noteAllDescendants (#1894)
* THREAD notifications to distinguish direct replies from follow-ups

* hotfix: typo

* hotfix: avoid subquery when we already have a JOIN
2025-02-11 13:45:04 -06:00
k00b
5af61f415f monospace font for edit countdowns 2025-02-10 19:19:22 -06:00
ekzyis
1ce88a216a
Merge pull request #1893 from stackernews/fix-welcome-script
Fix NaN in welcome script
2025-02-10 16:04:20 +01:00
ekzyis
a913c2d452 Fix NaN in welcome script 2025-02-10 01:39:13 +01:00
ekzyis
54afe67558
Merge pull request #1892 from stackernews/fix-welcome-script
Fix bios missed in welcome script
2025-02-10 01:33:21 +01:00
ekzyis
34aadba352 Fix bios missed in welcome script 2025-02-10 01:32:30 +01:00
Keyan
a8b3ee37bf
Update awards.csv 2025-02-08 18:42:41 -06:00
Keyan
64bbd2e1b8
Update awards.csv 2025-02-08 15:14:07 -06:00
soxa
c01f4865dc
apply flex rules after images are loaded (#1886) 2025-02-08 13:14:42 -06:00
ekzyis
2732013da3
FAQ feedback (#1888) 2025-02-07 17:37:15 -06:00
ekzyis
f90ed8d294
Add territory stats answer to FAQ (#1887) 2025-02-07 14:51:03 -06:00
soxa
5c2aa979ea
feat: comment fee control (#1768)
* feat: comment fee control

* update typeDefs for unarchiving territories

* review: move functions to top level; consider saloon items

* ux: cleaner post/reply cost section

* hotfix: handle salon replies

* bios don't have subs + simplify root query

* move reply cost to accordian

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-02-07 13:38:57 -06:00
ekzyis
ac321be3cd
Big FAQ update (#1800) 2025-02-07 12:53:11 -06:00
k00b
95e98501ec reduce max incoming invoice expiration and expiration buffer 2025-02-05 12:47:35 -06:00
k00b
ee8fe6e72a add boost badge fix #1860 2025-02-03 19:56:29 -06:00
k00b
9885bcf209 make sure comments specify time zone for invoicePaidAt fix #1859 2025-02-03 19:40:33 -06:00
jason-me
2dfde257d2
Update price button accessibility in header (#1857)
* Update price button accessibility

* Updated accName and accDescription for speech dictation and screen reader users.

* Update price.js

Replace double quote with single

* Update price.js

Remove trailing spaces

* make .visually-hidden global, use className rather than class

* make accessible button component

---------

Co-authored-by: Jason Hester <jhester@TPGLPT-LTC23.attlocal.net>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-02-03 19:22:02 -06:00
soxa
be7c702602
Login with magic code (#1818)
* fix: cannot login with email on PWA

* adjust other email templates

* restore manual url on new user email

* no padding on button section

* cleanup

* generate 6-digit bechh32 token

* token needs to be fed as lower case; validator case insensitive

* delete token if user has failed 3 times

* proposal: context-independent error page

* include expiration time on email page message

* add expiration time to emails

* independent checkPWA function

* restore token deletion if successful auth

* final cleanup: remove unused function

* compact useVerificationToken

* email.js: magic code for non-PWA users

* adjust email templates

* MultiInput component; magic code via MultiInput

* hotfix: revert length testing; larger width for inputs

* manual bech32 token generation; no upperCase

* reverting to string concatenation

* layout tweaks, fix error placement

* pastable inputs

* small nit fixes

* less ambiguous error path

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-02-03 18:41:01 -06:00
k00b
89187db1ea fix failing jobs 2025-02-01 15:36:48 -06:00
Keyan
074f0c0634
If we are a hop hint, use alternate form of estimateRouteFee (#1854)
* untested draft

* handle empty routes
2025-01-31 20:19:21 -06:00
k00b
efdcbef733 fixes related to comment paging 2025-01-30 19:19:49 -06:00
k00b
33beb1dc52 fix history race on flat comment click 2025-01-30 17:08:16 -06:00
k00b
bb916b8669 fix comments in notifications 2025-01-30 10:54:51 -06:00
k00b
312f4defb0 fix recursive limited comments call 2025-01-30 10:10:29 -06:00
Keyan
01b021a337
comment pagination with limit/offset (#1824)
* basic query with limit/offset

* roughly working increment

* working limiting/pageable queries

* denormalize direct comments + full comments below threshold

* notifications in megathread + working nest view more buttons

* fix empty comment footer

* make comments nested resolver again

* use time in cursor to avoid duplicates

* squash migrations

* do not need item.comments undefined checks
2025-01-29 19:00:05 -06:00
soxa
bd84b8bf88
fix: Images on iOS are cropped weird (#1840)
* force sync decoding on images

* use decode() to load the image

* add comment
2025-01-28 15:30:54 -06:00
k00b
965e482ea3 stimulus to 50k 2025-01-28 10:57:45 -06:00
Keyan
f8fa0f65e7
Update awards.csv 2025-01-27 18:49:58 -06:00
Keyan
8059945f82
Update awards.csv 2025-01-27 18:18:24 -06:00
ekzyis
ee2d076d1b
Welcome series script update (#1848)
* Parse args in welcome script

* Refactor welcome script

* Show sats/ccs

* Add sat standard
2025-01-27 15:14:03 -06:00
ekzyis
156b895fb6
Add createrune info for v24.11 (#1847) 2025-01-26 12:28:51 -06:00
ekzyis
53b8f6f956
FAQ formatting changes (#1842)
* Fix inconsistent markdown formatting in FAQ

- remove usage of &lrm;
- don't skip headers (##### instead of ###)

* Use hash links in FAQ
2025-01-25 13:48:51 -06:00
ekzyis
c023e8d7d5
Fix missing push notifications for thread subscriptions (#1843)
* Fix missing push notifications for thread subscriptions

* Filter by comments in calling context

* Fix mutes not considered

* Fix duplicate push notification (reply+thread subscription) sent
2025-01-25 13:47:58 -06:00
k00b
b28407ee99 remove changes from footer 2025-01-23 16:54:50 -06:00
soxa
78533bda1b
fix: downzappable pinned posts (#1841)
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-01-22 19:15:03 -06:00
soxa
47faef872d
Subscribe unarchiver to unarchived territory (#1839)
* enhance: subscribe unarchiver to unarchived territory

* use upsert and fix #1517

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-01-22 19:11:19 -06:00
ekzyis
ca7726fda5
Fix recent sort order for retried items (#1829)
* Fix recent sort order for retried items

* Also fix for comments

* don't hide createdAt, order item query inner subquery

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-01-22 17:42:18 -06:00
soxa
ae1942ada7
fix: duplicate push notification on subscribed user and territory (#1820)
* fix: duplicate notification on subscribed user and territory

* fix comments not showing up, adjust query

* use  and tagged template helpers

---------

Co-authored-by: k00b <k00b@stacker.news>
2025-01-22 16:12:41 -06:00
soxa
a92215ccf6
fix: globally pinned items rank in global (#1814)
* fix: globally pinned items rank in global; use query to filter global pinned items

* cleanup

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-01-22 15:23:21 -06:00
Keyan
382714e422
Merge pull request #1837 from stackernews/fix-possible-silent-push
Fix possible silent push
2025-01-21 09:37:10 -06:00
ekzyis
1942c79193 Fix possible silent push 2025-01-21 10:23:48 +01:00
Keyan
355abc7221
Merge pull request #1831 from stackernews/remove-msats-warning
Remove msats warning
2025-01-20 19:01:09 -06:00
ekzyis
181cb87c18 Remove warning if wallet does not support msats 2025-01-21 00:28:11 +01:00
Keyan
0c0fdfb63b
Update awards.csv 2025-01-20 15:51:04 -06:00
Keyan
020b914d0d
Update awards.csv 2025-01-20 15:32:18 -06:00
Keyan
0a83a88e06
Merge pull request #1828 from stackernews/fix-cc-info-typo
Fix typo in CC info
2025-01-20 12:04:56 -06:00
ekzyis
ac7bd5df7e Fix typo in CC info 2025-01-20 15:02:03 +01:00
Keyan
1057fcc04d
Merge pull request #1827 from stackernews/welcome
Script for welcome series
2025-01-19 19:30:49 -06:00
ekzyis
c6de7a1081 Script for welcome series 2025-01-19 23:56:19 +01:00
68 changed files with 1629 additions and 776 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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`

View File

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

View File

@ -124,9 +124,12 @@ export default gql`
withdrawl: Withdrawl!
}
union ReferralSource = Item | Sub | User
type Referral {
id: ID!
sortTime: Date!
source: ReferralSource
}
type SubStatus {

View File

@ -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!

View File

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

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
160 Soxasora pr #1685 medium 250k soxasora@blink.sv 2024-12-07
161 aegroto pr #1606 #1242 medium 250k aegroto@blink.sv 2024-12-07
162 sfr0xyz issue #1696 #1196 good-first-issue 2k sefiro@getalby.com 2024-12-10
163 Soxasora pr #1794 #756 hard urgent includes #411 3m bolt11 2024-01-09 2025-01-09
164 Soxasora pr #1794 #1786 #411 #363 hard easy high sort of grouped with #1794 1m 100k bolt11 2024-01-09 2025-01-09
165 SatsAllDay Soxasora issue pr #1749 #1768 #411 #1186 hard medium-hard high 200k 500k weareallsatoshi@getalby.com bolt11 ??? 2025-01-09
166 Soxasora pr #1786 #1750 #363 #1035 easy medium 100k 250k soxasora@blink.sv bolt11 ??? 2025-01-09
167 felipebueno SatsAllDay issue #1786 #1794 #363 #411 easy hard high 10k 200k felipebueno@getalby.com weareallsatoshi@getalby.com ??? 2025-01-20
168 cyphercosmo felipebueno pr issue #1745 #1786 #1648 #363 good-first-issue easy 2 16k 10k cyphercosmo@getalby.com felipebueno@blink.sv ??? 2025-01-27
169 cyphercosmo pr #1745 #1648 good-first-issue 2 16k cyphercosmo@getalby.com 2025-01-27
170 Radentor issue #1768 #1186 medium-hard 50k revisedbird84@walletofsatoshi.com 2025-01-27
171 Soxasora pr #1841 #1692 good-first-issue 20k soxasora@blink.sv 2025-01-27
172 Soxasora pr #1839 #1790 easy 1 90k soxasora@blink.sv 2025-01-27
173 Soxasora pr #1820 #1819 easy 1 90k soxasora@blink.sv 2025-01-27
174 SatsAllDay issue #1820 #1819 easy 1 9k weareallsatoshi@getalby.com 2025-01-27
175 Soxasora pr #1814 #1736 easy 100k soxasora@blink.sv 2025-01-27
176 jason-me pr #1857 easy 100k rrbtc@vlt.ge 2025-02-08

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -200,6 +200,10 @@
}
.p.onlyImages {
display: block;
}
.p.onlyImages:has(> .mediaContainer.loaded) {
display: flex;
flex-direction: row;
flex-wrap: wrap;

View File

@ -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
&lrm;
##### 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.
&lrm;
##### 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".
&lrm;
##### 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.
&lrm;
##### 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.
&lrm;
##### 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?
&lrm;
##### 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?
&lrm;
##### Can I Use Multiple Login Methods?
There are four ways to earn sats on Stacker News:
Yes.
**1. Zaps**
Once youre 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 youve linked another authentication method to your account, youll be able to access your account on any device using any one of your linked authentication methods.
**2. Daily rewards**
&lrm;
##### 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.
&lrm;
##### 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
&lrm;
##### 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:
&lrm;
###### QR code
![](https://m.stacker.news/75164)
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:
&lrm;
###### 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.
&lrm;
###### 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).
&lrm;
##### 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 ↗️.
&lrm;
##### 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.
&lrm;
##### 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?
&lrm;
##### 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?
&lrm;
##### 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
&lrm;
##### 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).
&lrm;
##### 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:
&lrm;
##### 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.
&lrm;
##### 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.
&lrm;
##### 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
![](https://m.stacker.news/76254)
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.
&lrm;
##### 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:
![](https://m.stacker.news/76546)
- 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?
&lrm;
##### 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 youd like to zap, and click 'zap'.
&lrm;
##### 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.
&lrm;
##### 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.
&lrm;
##### 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.
&lrm;
##### 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.
&lrm;
##### 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.
&lrm;
##### 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
&lrm;
##### 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
&lrm;
##### 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.
&lrm;
##### 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.
&lrm;
##### 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.
&lrm;
##### 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
&lrm;
##### 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.
&lrm;
##### 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
&lrm;
##### 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 youd like to zap, and click zap.
&lrm;
##### 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.
&lrm;
##### 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?
&lrm;
##### 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.
&lrm;
##### 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.
&lrm;
##### 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.
&lrm;
##### 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.
&lrm;
##### 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
&lrm;
##### 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
&lrm;
##### 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.
&lrm;
##### 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 stackers 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 stackers 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).
&lrm;
##### 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.
&lrm;
##### 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.
&lrm;
##### 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?
&lrm;
##### 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
---
&lrm;
##### 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).
&lrm;
##### 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.
&lrm;
##### How Do I View My Past Stacker News Transactions?
A stackers 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
&lrm;
##### 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).
&lrm;
##### 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).
&lrm;
##### 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?
&lrm;
##### Where Should I Submit Bug Reports?
Bug reports can be submitted on our git repo: https://github.com/stackernews/stacker.news/issues.
&lrm;
##### 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).
&lrm;
##### Where Can I Ask More Questions?
Reply to this FAQ. It's like any other post on the site.

View File

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

View File

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

View File

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

View File

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

View File

@ -25,6 +25,7 @@ export const SUB_FIELDS = gql`
billedLastAt
billPaidUntil
baseCost
replyCost
userId
desc
status

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, '&#8203;.')}`
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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Sub" ADD COLUMN "replyCost" INTEGER NOT NULL DEFAULT 1;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "verification_requests" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0;

View 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
$$;

View File

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

View File

@ -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
$$;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OneDayReferral" ADD COLUMN "landing" BOOLEAN NOT NULL DEFAULT false;

View File

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

View File

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

View File

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

View File

@ -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==',

View File

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

View File

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

View File

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

View File

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

View File

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