Compare commits

...

25 Commits

Author SHA1 Message Date
Keyan
a669ec832b
Update README.md 2025-03-20 13:28:26 -05:00
k00b
7dccd383c3 fix hot score on limited comment queries fix #1996 2025-03-20 12:21:20 -05:00
ekzyis
271563efbd
Fix space before question mark in delete prompt (#1995) 2025-03-20 10:48:17 -05:00
ekzyis
ada230597d
Fix anon dropdown button width (#1997) 2025-03-20 10:46:07 -05:00
Edward Kung
08501583df
reset nym editting state on page change (#1993) 2025-03-19 18:55:22 -05:00
ekzyis
74d99e9b74
Reset multi_auth cookies on error (#1957)
* multi_auth cookies check + reset

* multi_auth cookies refresh

* Expire cookies after 30 days

This is the actual default for next-auth.session-token.

* Collapse issues by default

* Only refresh session cookie manually as anon

* fix mangled merge

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-03-19 18:54:43 -05:00
soxa
71caa6d0fe
Prevent new account creation on login (#1976)
* Prevent account creation if we're not signin up

* remove cookie once logged in, 24 hours expiry, comment

* adjust error messages

* check signin instead of signup

* appendHeader to avoid overwrites, fix typo, use NodeNextRequest to handle cookies

* expire cookie if signup
2025-03-19 16:55:38 -05:00
k00b
4f17615291 fix expireBoost boolean condition 2025-03-19 11:48:36 -05:00
k00b
0e4b467b3c make expire boost exclude unpaid invoices 2025-03-19 11:44:35 -05:00
Edward Kung
9905e6eafe
Top cowboys territory selector fix (#1972)
* fix territory selector when in top/cowboys

* redirect /~sub/top/cowboys to /top/cowboys

* check if pathname ends with /top/cowboys

Co-authored-by: ekzyis <ek@stacker.news>

* fix territory selector in top/stackers and top/territories

* better routing logic

---------

Co-authored-by: ekzyis <ek@stacker.news>
2025-03-19 08:19:19 -05:00
github-actions[bot]
fc6cbba40c
Extending awards.csv (#1988)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-03-18 18:18:58 -05:00
ekzyis
60f628e77e
Fix no pw manager autofill for device sync password (#1953)
* Fix no pw manager autofill for device sync password

* Remove unused rows property
2025-03-18 18:17:23 -05:00
soxa
63704a5f0f
Hide pull-to-refresh when not pulling (#1986) 2025-03-18 18:16:36 -05:00
ekzyis
964cdc1d61
Fix warning about missing key for children (#1987) 2025-03-18 17:48:13 -05:00
Scroogey-SN
e31f8e9c69
Support .onion for phoenixd (#1975)
* fix #1964 support .onion urls like lnbits does

* fix lint: const hostname

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-03-18 15:39:53 -05:00
k00b
b7dfef41c0 make search query work with os2.17 and upgrade containers 2025-03-18 14:01:00 -05:00
k00b
344c23ed5c use max zap creation 2025-03-17 20:03:05 -05:00
Edward Kung
b71398a06c
Search improvements: Add relevance search and make recent searches less strict (#1962)
* reconfigured search pipeline

* remove console debug messages

* log1p for comments

* improve relevance of non-relevance sorted queries

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-03-17 19:25:20 -05:00
github-actions[bot]
1a52ff7784
Extending awards.csv (#1978)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-03-17 14:31:00 -05:00
Scroogey-SN
a3762f70b0
fix #1959 strip trailing slashes from url (#1973) 2025-03-17 13:16:31 -05:00
ekzyis
c492618d31
Fix weeks not support in reminder command (#1977) 2025-03-17 13:15:09 -05:00
k00b
4be7f12119 add another hot_score index without nulls clause 2025-03-15 15:29:01 -05:00
Keyan
b672d015e2
territory specific trust (#1965)
* territory specific trust

* functional parity with master

* revert back to materialized view for ranking

* update query for populating subWeightedVotes

* fix anon hot comments

* fix zap denormalization, change weightedComments to be for zaps, order updates of ancestors to prevent deadlocks

* reduce weight of comment zaps for hot score

* do zap ancestor updates together

* initialize trust in new/unpopular territories

* simplify denormalization of zap/downzaps

* recompute all scores
2025-03-15 08:11:33 -05:00
Keyan
53f6c34ee7
Merge pull request #1974 from stackernews/rename-to-next-account
Fix missing rename to nextAccount
2025-03-15 08:06:21 -05:00
ekzyis
54e7793668 Fix missing rename to nextAccount 2025-03-14 22:01:55 -05:00
37 changed files with 1402 additions and 594 deletions

View File

@ -5,7 +5,7 @@
</p>
- Stacker News makes internet communities that pay you Bitcoin
- Stacker News is trying to fix online communities with economics
- What You See is What We Ship (look ma, I invented an initialism)
- 100% FOSS
- We pay bitcoin for PRs, issues, documentation, code reviews and more

View File

@ -1,5 +1,6 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { Prisma } from '@prisma/client'
export const anonable = false
@ -48,9 +49,9 @@ export async function onPaid ({ invoice, actId }, { tx }) {
let itemAct
if (invoice) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id } })
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id }, include: { item: true } })
} else if (actId) {
itemAct = await tx.itemAct.findUnique({ where: { id: actId } })
itemAct = await tx.itemAct.findUnique({ where: { id: actId }, include: { item: true } })
} else {
throw new Error('No invoice or actId')
}
@ -60,19 +61,34 @@ export async function onPaid ({ invoice, actId }, { tx }) {
// denormalize downzaps
await tx.$executeRaw`
WITH zapper AS (
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER
), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
ON CONFLICT ("itemId", "userId") DO UPDATE
SET "downZapSats" = "ItemUserAgg"."downZapSats" + ${sats}::INTEGER, updated_at = now()
RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
)
UPDATE "Item"
SET "weightedDownVotes" = "weightedDownVotes" + (zapper.trust * zap.log_sats)
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
WITH territory AS (
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
WHERE i.id = ${itemAct.itemId}::INTEGER
), zapper AS (
SELECT
COALESCE(${itemAct.item.parentId
? Prisma.sql`"zapCommentTrust"`
: Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust",
COALESCE(${itemAct.item.parentId
? Prisma.sql`"subZapCommentTrust"`
: Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust"
FROM territory
LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName"
AND ust."userId" = ${itemAct.userId}::INTEGER
), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
ON CONFLICT ("itemId", "userId") DO UPDATE
SET "downZapSats" = "ItemUserAgg"."downZapSats" + ${sats}::INTEGER, updated_at = now()
RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
)
UPDATE "Item"
SET "weightedDownVotes" = "weightedDownVotes" + zapper."zapTrust" * zap.log_sats,
"subWeightedDownVotes" = "subWeightedDownVotes" + zapper."subZapTrust" * zap.log_sats
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
}
export async function onFail ({ invoice }, { tx }) {

View File

@ -252,15 +252,18 @@ export async function onPaid ({ invoice, id }, context) {
JOIN users ON "Item"."userId" = users.id
WHERE "Item".id = ${item.id}::INTEGER
), ancestors AS (
SELECT "Item".*
FROM "Item", comment
WHERE "Item".path @> comment.path AND "Item".id <> comment.id
ORDER BY "Item".id
), updated_ancestors AS (
UPDATE "Item"
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,
"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
FROM comment, ancestors
WHERE "Item".id = ancestors.id
RETURNING "Item".*
)
INSERT INTO "Reply" (created_at, updated_at, "ancestorId", "ancestorUserId", "itemId", "userId", level)

View File

@ -0,0 +1,27 @@
import { USER_ID } from '@/lib/constants'
export const GLOBAL_SEEDS = [USER_ID.k00b, USER_ID.ek]
export function initialTrust ({ name, userId }) {
const results = GLOBAL_SEEDS.map(id => ({
subName: name,
userId: id,
zapPostTrust: 1,
subZapPostTrust: 1,
zapCommentTrust: 1,
subZapCommentTrust: 1
}))
if (!GLOBAL_SEEDS.includes(userId)) {
results.push({
subName: name,
userId,
zapPostTrust: 0,
subZapPostTrust: 1,
zapCommentTrust: 0,
subZapCommentTrust: 1
})
}
return results
}

View File

@ -1,6 +1,7 @@
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory'
import { initialTrust } from './lib/territory'
export const anonable = false
@ -20,7 +21,7 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
const billedLastAt = new Date()
const billPaidUntil = nextBilling(billedLastAt, billingType)
return await tx.sub.create({
const sub = await tx.sub.create({
data: {
...data,
billedLastAt,
@ -42,6 +43,12 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
}
}
})
await tx.userSubTrust.createMany({
data: initialTrust({ name: sub.name, userId: sub.userId })
})
return sub
}
export async function describe ({ name }) {

View File

@ -1,6 +1,7 @@
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory'
import { initialTrust } from './lib/territory'
export const anonable = false
@ -65,7 +66,7 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
}
})
return await tx.sub.update({
const updatedSub = await tx.sub.update({
data,
// optimistic concurrency control
// make sure none of the relevant fields have changed since we fetched the sub
@ -76,6 +77,12 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
}
}
})
await tx.userSubTrust.createMany({
data: initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
})
return updatedSub
}
export async function describe ({ name }, context) {

View File

@ -2,6 +2,7 @@ import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { notifyZapped } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server'
import { Prisma } from '@prisma/client'
export const anonable = true
@ -149,8 +150,22 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
await tx.$queryRaw`
WITH zapper AS (
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER
WITH territory AS (
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
WHERE i.id = ${itemAct.itemId}::INTEGER
), zapper AS (
SELECT
COALESCE(${itemAct.item.parentId
? Prisma.sql`"zapCommentTrust"`
: Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust",
COALESCE(${itemAct.item.parentId
? Prisma.sql`"subZapCommentTrust"`
: Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust"
FROM territory
LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName"
AND ust."userId" = ${itemAct.userId}::INTEGER
), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
@ -158,17 +173,30 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
SET "zapSats" = "ItemUserAgg"."zapSats" + ${sats}::INTEGER, updated_at = now()
RETURNING ("zapSats" = ${sats}::INTEGER)::INTEGER as first_vote,
LOG("zapSats" / GREATEST("zapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
), item_zapped AS (
UPDATE "Item"
SET
"weightedVotes" = "weightedVotes" + zapper."zapTrust" * zap.log_sats,
"subWeightedVotes" = "subWeightedVotes" + zapper."subZapTrust" * zap.log_sats,
upvotes = upvotes + zap.first_vote,
msats = "Item".msats + ${msats}::BIGINT,
mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT,
"lastZapAt" = now()
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER
RETURNING "Item".*, zapper."zapTrust" * zap.log_sats as "weightedVote"
), ancestors AS (
SELECT "Item".*
FROM "Item", item_zapped
WHERE "Item".path @> item_zapped.path AND "Item".id <> item_zapped.id
ORDER BY "Item".id
)
UPDATE "Item"
SET
"weightedVotes" = "weightedVotes" + (zapper.trust * zap.log_sats),
upvotes = upvotes + zap.first_vote,
msats = "Item".msats + ${msats}::BIGINT,
mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT,
"lastZapAt" = now()
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER
RETURNING "Item".*`
SET "weightedComments" = "Item"."weightedComments" + item_zapped."weightedVote",
"commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT,
"commentMcredits" = "Item"."commentMcredits" + ${invoice?.invoiceForward ? 0n : msats}::BIGINT
FROM item_zapped, ancestors
WHERE "Item".id = ancestors.id`
// record potential bounty payment
// NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust
@ -188,17 +216,6 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL)
FROM bounty
WHERE "Item".id = bounty.id AND bounty.paid`
// update commentMsats on ancestors
await tx.$executeRaw`
WITH zapped AS (
SELECT * FROM "Item" WHERE id = ${itemAct.itemId}::INTEGER
)
UPDATE "Item"
SET "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT,
"commentMcredits" = "Item"."commentMcredits" + ${invoice?.invoiceForward ? 0n : msats}::BIGINT
FROM zapped
WHERE "Item".path @> zapped.path AND "Item".id <> zapped.id`
}
export async function nonCriticalSideEffects ({ invoice, actIds }, { models }) {

View File

@ -39,16 +39,12 @@ function commentsOrderByClause (me, models, sort) {
COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC`
}
if (me && sort === 'hot') {
if (sort === 'hot') {
return `ORDER BY ${sharedSorts},
"personal_hot_score" DESC NULLS LAST,
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
"hotScore" DESC NULLS LAST,
"Item".msats DESC, "Item".id DESC`
} else {
if (sort === 'top') {
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 ${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`
}
return `ORDER BY ${sharedSorts}, "Item"."weightedVotes" - "Item"."weightedDownVotes" DESC NULLS LAST, "Item".msats DESC, "Item".id DESC`
}
}
@ -138,14 +134,14 @@ export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { m
}, ...subArr))?.[0] || null
}
const orderByClause = (by, me, models, type) => {
const orderByClause = (by, me, models, type, sub) => {
switch (by) {
case 'comments':
return 'ORDER BY "Item".ncomments DESC'
case 'sats':
return 'ORDER BY "Item".msats DESC'
case 'zaprank':
return topOrderByWeightedSats(me, models)
return topOrderByWeightedSats(me, models, sub)
case 'boost':
return 'ORDER BY "Item".boost DESC'
case 'random':
@ -155,22 +151,8 @@ const orderByClause = (by, me, models, type) => {
}
}
export function orderByNumerator ({ models, commentScaler = 0.5, considerBoost = false }) {
return `((CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0 THEN
GREATEST("Item"."weightedVotes" - "Item"."weightedDownVotes", POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", 1.2))
ELSE
"Item"."weightedVotes" - "Item"."weightedDownVotes"
END + "Item"."weightedComments"*${commentScaler}) + ${considerBoost ? `("Item".boost / ${BOOST_MULT})` : 0})`
}
export function joinZapRankPersonalView (me, models) {
let join = ` JOIN zap_rank_personal_view g ON g.id = "Item".id AND g."viewerId" = ${GLOBAL_SEED} `
if (me) {
join += ` LEFT JOIN zap_rank_personal_view l ON l.id = g.id AND l."viewerId" = ${me.id} `
}
return join
export function joinHotScoreView (me, models) {
return ' JOIN hot_score_view g ON g.id = "Item".id '
}
// this grabs all the stuff we need to display the item list and only
@ -475,10 +457,10 @@ export default {
await filterClause(me, models, type),
by === 'boost' && '"Item".boost > 0',
muteClause(me))}
${orderByClause(by || 'zaprank', me, models, type)}
${orderByClause(by || 'zaprank', me, models, type, sub)}
OFFSET $3
LIMIT $4`,
orderBy: orderByClause(by || 'zaprank', me, models, type)
orderBy: orderByClause(by || 'zaprank', me, models, type, sub)
}, ...whenRange(when, from, to || decodedCursor.time), decodedCursor.offset, limit, ...subArr)
break
case 'random':
@ -571,10 +553,10 @@ export default {
me,
models,
query: `
${SELECT}, ${me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score'} AS rank
${SELECT}, g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore"
FROM "Item"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${joinZapRankPersonalView(me, models)}
${joinHotScoreView(me, models)}
${whereClause(
// 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)',
@ -587,40 +569,11 @@ export default {
await filterClause(me, models, type),
subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me))}
ORDER BY rank DESC
ORDER BY ${sub ? '"subHotScore"' : '"hotScore"'} DESC, "Item".msats DESC, "Item".id DESC
OFFSET $1
LIMIT $2`,
orderBy: 'ORDER BY rank DESC'
orderBy: `ORDER BY ${sub ? '"subHotScore"' : '"hotScore"'} DESC, "Item".msats DESC, "Item".id DESC`
}, decodedCursor.offset, limit, ...subArr)
// XXX this is mostly for subs that are really empty
if (items.length < limit) {
items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${whereClause(
subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me),
// 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',
ad ? `"Item".id <> ${ad.id}` : '',
activeOrMine(me),
await filterClause(me, models, type))}
ORDER BY ${orderByNumerator({ models, 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
OFFSET $1
LIMIT $2`,
orderBy: `ORDER BY ${orderByNumerator({ models, 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`
}, decodedCursor.offset, limit, ...subArr)
}
break
}
break
@ -1574,6 +1527,9 @@ export const SELECT =
`SELECT "Item".*, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt",
ltree2text("Item"."path") AS "path"`
function topOrderByWeightedSats (me, models) {
return `ORDER BY ${orderByNumerator({ models })} DESC NULLS LAST, "Item".id DESC`
function topOrderByWeightedSats (me, models, sub) {
if (sub) {
return 'ORDER BY "Item"."subWeightedVotes" - "Item"."subWeightedDownVotes" DESC, "Item".msats DESC, "Item".id DESC'
}
return 'ORDER BY "Item"."weightedVotes" - "Item"."weightedDownVotes" DESC, "Item".msats DESC, "Item".id DESC'
}

View File

@ -174,7 +174,6 @@ export default {
search: async (parent, { q, cursor, sort, what, when, from: whenFrom, to: whenTo }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor)
let sitems = null
let termQueries = []
// short circuit: return empty result if either:
// 1. no query provided, or
@ -186,56 +185,116 @@ export default {
}
}
const whatArr = []
// build query in parts:
// filters: determine the universe of potential search candidates
// termQueries: queries related to the actual search terms
// functions: rank modifiers to boost by recency or popularity
const filters = []
const termQueries = []
const functions = []
// filters for item types
switch (what) {
case 'posts':
whatArr.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
case 'posts': // posts only
filters.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
break
case 'comments':
whatArr.push({ bool: { must: { exists: { field: 'parentId' } } } })
case 'comments': // comments only
filters.push({ bool: { must: { exists: { field: 'parentId' } } } })
break
case 'bookmarks':
if (me?.id) {
whatArr.push({ match: { bookmarkedBy: me?.id } })
filters.push({ match: { bookmarkedBy: me?.id } })
}
break
default:
break
}
// filter for active posts
filters.push(
me
? {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } }
]
}
}
: {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
}
)
// filter for time range
const whenRange = when === 'custom'
? {
gte: whenFrom,
lte: new Date(Math.min(new Date(Number(whenTo)), decodedCursor.time))
}
: {
lte: decodedCursor.time,
gte: whenToFrom(when)
}
filters.push({ range: { createdAt: whenRange } })
// filter for non negative wvotes
filters.push({ range: { wvotes: { gte: 0 } } })
// decompose the search terms
const { query: _query, quotes, nym, url, territory } = queryParts(q)
let query = _query
const isUrlSearch = url && query.length === 0 // exclusively searching for an url
const query = _query
// if search contains a url term, modify the query text
if (url) {
const isFQDN = url.startsWith('url:www.')
const domain = isFQDN ? url.slice(8) : url.slice(4)
const fqdn = `www.${domain}`
query = (isUrlSearch) ? `${domain} ${fqdn}` : `${query.trim()} ${domain}`
}
if (nym) {
whatArr.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
}
if (territory) {
whatArr.push({ match: { 'sub.name': territory.slice(1) } })
}
termQueries.push({
// all terms are matched in fields
multi_match: {
query,
type: 'best_fields',
fields: ['title^100', 'text'],
minimum_should_match: (isUrlSearch) ? 1 : '100%',
boost: 1000
const uri = url.slice(4)
let uriObj
try {
uriObj = new URL(uri)
} catch {
try {
uriObj = new URL(`https://${uri}`)
} catch {}
}
})
if (uriObj) {
termQueries.push({
wildcard: { url: `*${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}*` }
})
termQueries.push({
match: { text: `${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}` }
})
}
}
// if nym, items must contain nym
if (nym) {
filters.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
}
// if territory, item must be from territory
if (territory) {
filters.push({ match: { 'sub.name': territory.slice(1) } })
}
// if quoted phrases, items must contain entire phrase
for (const quote of quotes) {
whatArr.push({
termQueries.push({
multi_match: {
query: quote,
type: 'phrase',
fields: ['title', 'text']
}
})
// force the search to include the quoted phrase
filters.push({
multi_match: {
query: quote,
type: 'phrase',
@ -244,84 +303,104 @@ export default {
})
}
// if we search for an exact string only, everything must match
// so score purely on sort field
let boostMode = query ? 'multiply' : 'replace'
let sortField
let sortMod = 'log1p'
// functions for boosting search rank by recency or popularity
switch (sort) {
case 'comments':
sortField = 'ncomments'
sortMod = 'square'
functions.push({
field_value_factor: {
field: 'ncomments',
modifier: 'log1p'
}
})
break
case 'sats':
sortField = 'sats'
functions.push({
field_value_factor: {
field: 'sats',
modifier: 'log1p'
}
})
break
case 'recent':
sortField = 'createdAt'
sortMod = 'square'
boostMode = 'replace'
functions.push({
gauss: {
createdAt: {
origin: 'now',
scale: '7d',
decay: 0.5
}
}
})
break
case 'zaprank':
functions.push({
field_value_factor: {
field: 'wvotes',
modifier: 'log1p'
}
})
break
default:
sortField = 'wvotes'
sortMod = 'none'
break
}
const functions = [
{
field_value_factor: {
field: sortField,
modifier: sortMod,
factor: 1.2
}
let osQuery = {
function_score: {
query: {
bool: {
filter: filters,
should: termQueries,
minimum_should_match: termQueries.length > 0 ? 1 : 0
}
},
functions,
score_mode: 'multiply',
boost_mode: 'multiply'
}
]
if (sort === 'recent' && !isUrlSearch) {
// prioritize exact matches
termQueries.push({
multi_match: {
query,
type: 'phrase',
fields: ['title^100', 'text'],
boost: 1000
}
})
} else {
// allow fuzzy matching with partial matches
termQueries.push({
multi_match: {
query,
type: 'most_fields',
fields: ['title^100', 'text'],
fuzziness: 'AUTO',
prefix_length: 3,
minimum_should_match: (isUrlSearch) ? 1 : '60%'
}
})
functions.push({
// small bias toward posts with comments
field_value_factor: {
field: 'ncomments',
modifier: 'ln1p',
factor: 1
}
},
{
// small bias toward recent posts
field_value_factor: {
field: 'createdAt',
modifier: 'log1p',
factor: 1
}
})
}
// query for search terms
if (query.length) {
// if we have a model id and we aren't sort by recent, use neural search
if (process.env.OPENSEARCH_MODEL_ID && sort !== 'recent') {
termQueries = {
// keyword based subquery, to be used on its own or in conjunction with a neural
// search
const subquery = [
{
multi_match: {
query,
type: 'best_fields',
fields: ['title^10', 'text'],
fuzziness: 'AUTO',
minimum_should_match: 1
}
},
// all match matches higher
{
multi_match: {
query,
type: 'best_fields',
fields: ['title^10', 'text'],
minimum_should_match: '100%',
boost: 100
}
},
// phrase match matches higher
{
multi_match: {
query,
type: 'phrase',
fields: ['title^10', 'text'],
boost: 1000
}
}
]
osQuery.function_score.query.bool.should = [...termQueries, ...subquery]
osQuery.function_score.query.bool.minimum_should_match = 1
// use hybrid neural search if model id is available, otherwise use only
// keyword search
if (process.env.OPENSEARCH_MODEL_ID) {
osQuery = {
hybrid: {
queries: [
{
@ -345,32 +424,18 @@ export default {
}
}
}
]
],
filter: filters,
minimum_should_match: 1
}
},
{
bool: {
should: termQueries
}
}
osQuery
]
}
}
}
} else {
termQueries = []
}
const whenRange = when === 'custom'
? {
gte: whenFrom,
lte: new Date(Math.min(new Date(Number(whenTo)), decodedCursor.time))
}
: {
lte: decodedCursor.time,
gte: whenToFrom(when)
}
try {
sitems = await search.search({
index: process.env.OPENSEARCH_INDEX,
@ -384,45 +449,7 @@ export default {
},
from: decodedCursor.offset,
body: {
query: {
function_score: {
query: {
bool: {
must: termQueries,
filter: [
...whatArr,
me
? {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } }
]
}
}
: {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
},
{
range:
{
createdAt: whenRange
}
},
{ range: { wvotes: { gte: 0 } } }
]
}
},
functions,
boost_mode: boostMode
}
},
query: osQuery,
highlight: {
fields: {
title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
@ -458,7 +485,7 @@ export default {
${SELECT}, rank
FROM "Item"
JOIN r ON "Item".id = r.id`,
orderBy: 'ORDER BY rank ASC'
orderBy: 'ORDER BY rank ASC, msats DESC'
})).map((item, i) => {
const e = sitems.body.hits.hits[i]
item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title

View File

@ -189,3 +189,5 @@ Scroogey-SN,pr,#1948,#1849,medium,urgent,,,750k,Scroogey@coinos.io,2025-03-10
felipebueno,issue,#1947,#1945,good-first-issue,,,,2k,felipebueno@blink.sv,2025-03-10
ed-kung,pr,#1952,#1951,easy,,,,100k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1952,#1951,easy,,,,10k,simplestacker@getalby.com,2025-03-10
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,???
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,???,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
189 felipebueno issue #1947 #1945 good-first-issue 2k felipebueno@blink.sv 2025-03-10
190 ed-kung pr #1952 #1951 easy 100k simplestacker@getalby.com 2025-03-10
191 ed-kung issue #1952 #1951 easy 10k simplestacker@getalby.com 2025-03-10
192 Scroogey-SN pr #1973 #1959 good-first-issue 20k Scroogey@coinos.io ???
193 benthecarman issue #1953 #1950 good-first-issue 2k ??? ???

View File

@ -8,40 +8,31 @@ import { useQuery } from '@apollo/client'
import { UserListRow } from '@/components/user-list'
import Link from 'next/link'
import AddIcon from '@/svgs/add-fill.svg'
import { MultiAuthErrorBanner } from '@/components/banners'
const AccountContext = createContext()
const CHECK_ERRORS_INTERVAL_MS = 5_000
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
const maybeSecureCookie = cookie => {
return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie
}
export const AccountProvider = ({ children }) => {
const { me } = useMe()
const [accounts, setAccounts] = useState([])
const [meAnon, setMeAnon] = useState(true)
const [errors, setErrors] = useState([])
const updateAccountsFromCookie = useCallback(() => {
try {
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
const accounts = multiAuthCookie
? JSON.parse(b64Decode(multiAuthCookie))
: me ? [{ id: Number(me.id), name: me.name, photoId: me.photoId }] : []
setAccounts(accounts)
// required for backwards compatibility: sync cookie with accounts if no multi auth cookie exists
// this is the case for sessions that existed before we deployed account switching
if (!multiAuthCookie && !!me) {
document.cookie = maybeSecureCookie(`multi_auth=${b64Encode(accounts)}; Path=/`)
}
} catch (err) {
console.error('error parsing cookies:', err)
}
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
const accounts = multiAuthCookie
? JSON.parse(b64Decode(multiAuthCookie))
: []
setAccounts(accounts)
}, [])
useEffect(updateAccountsFromCookie, [])
const addAccount = useCallback(user => {
setAccounts(accounts => [...accounts, user])
}, [])
@ -50,7 +41,7 @@ export const AccountProvider = ({ children }) => {
setAccounts(accounts => accounts.filter(({ id }) => id !== userId))
}, [])
const multiAuthSignout = useCallback(async () => {
const nextAccount = useCallback(async () => {
const { status } = await fetch('/api/next-account', { credentials: 'include' })
// if status is 302, this means the server was able to switch us to the next available account
// and the current account was simply removed from the list of available accounts including the corresponding JWT.
@ -59,15 +50,43 @@ export const AccountProvider = ({ children }) => {
return switchSuccess
}, [updateAccountsFromCookie])
useEffect(() => {
if (SSR) return
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie)
setMeAnon(multiAuthUserIdCookie === 'anonymous')
const checkErrors = useCallback(() => {
const {
multi_auth: multiAuthCookie,
'multi_auth.user-id': multiAuthUserIdCookie
} = cookie.parse(document.cookie)
const errors = []
if (!multiAuthCookie) errors.push('multi_auth cookie not found')
if (!multiAuthUserIdCookie) errors.push('multi_auth.user-id cookie not found')
setErrors(errors)
}, [])
useEffect(() => {
if (SSR) return
updateAccountsFromCookie()
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie)
setMeAnon(multiAuthUserIdCookie === 'anonymous')
const interval = setInterval(checkErrors, CHECK_ERRORS_INTERVAL_MS)
return () => clearInterval(interval)
}, [updateAccountsFromCookie, checkErrors])
const value = useMemo(
() => ({ accounts, addAccount, removeAccount, meAnon, setMeAnon, multiAuthSignout }),
[accounts, addAccount, removeAccount, meAnon, setMeAnon, multiAuthSignout])
() => ({
accounts,
addAccount,
removeAccount,
meAnon,
setMeAnon,
nextAccount,
multiAuthErrors: errors
}),
[accounts, addAccount, removeAccount, meAnon, setMeAnon, nextAccount, errors])
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
}
@ -129,9 +148,23 @@ const AccountListRow = ({ account, ...props }) => {
}
export default function SwitchAccountList () {
const { accounts } = useAccounts()
const { accounts, multiAuthErrors } = useAccounts()
const router = useRouter()
const hasError = multiAuthErrors.length > 0
if (hasError) {
return (
<>
<div className='my-2'>
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
<MultiAuthErrorBanner errors={multiAuthErrors} />
</div>
</div>
</>
)
}
// can't show hat since the streak is not included in the JWT payload
return (
<>

View File

@ -6,6 +6,7 @@ import { useMutation } from '@apollo/client'
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
import { useToast } from '@/components/toast'
import Link from 'next/link'
import AccordianItem from '@/components/accordian-item'
export function WelcomeBanner ({ Banner }) {
const { me } = useMe()
@ -123,3 +124,24 @@ export function AuthBanner () {
</Alert>
)
}
export function MultiAuthErrorBanner ({ errors }) {
return (
<Alert className={styles.banner} key='info' variant='danger'>
<div className='fw-bold mb-3'>Account switching is currently unavailable</div>
<AccordianItem
className='my-3'
header='We have detected the following issues:'
headerColor='var(--bs-danger-text-emphasis)'
body={
<ul>
{errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
}
/>
<div className='mt-3'>To resolve these issues, please sign out and sign in again.</div>
</Alert>
)
}

View File

@ -1,7 +1,7 @@
import { signIn } from 'next-auth/react'
import styles from './login.module.css'
import { Form, Input, SubmitButton } from '@/components/form'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Alert from 'react-bootstrap/Alert'
import { useRouter } from 'next/router'
import { LightningAuthWithExplainer } from './lightning-auth'
@ -42,10 +42,10 @@ 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: 'Try again or choose a different method.',
Callback: 'Could not authenticate. 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.',
CredentialsSignin: 'Could not authenticate. Try again or choose a different method.',
default: 'Auth failed. Try again or choose a different method.'
}
@ -53,10 +53,23 @@ export function authErrorMessage (error) {
return error && (authErrorMessages[error] ?? authErrorMessages.default)
}
export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer }) {
export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer, signin }) {
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error))
const router = useRouter()
// signup/signin awareness cookie
useEffect(() => {
const cookieOptions = [
`signin=${!!signin}`,
'path=/',
'max-age=' + (signin ? 60 * 60 * 24 : 0), // 24 hours if signin is true, expire the cookie otherwise
'SameSite=Lax',
process.env.NODE_ENV === 'production' ? 'Secure' : ''
].filter(Boolean).join(';')
document.cookie = cookieOptions
}, [signin])
if (router.query.type === 'lightning') {
return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} multiAuth={multiAuth} />
}
@ -112,6 +125,7 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
default:
return (
<OverlayTrigger
key={provider.id}
placement='bottom'
overlay={multiAuth ? <Tooltip>not available for account switching yet</Tooltip> : <></>}
trigger={['hover', 'focus']}
@ -119,7 +133,6 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
<div className='w-100'>
<LoginButton
className={`mt-2 ${styles.providerButton}`}
key={provider.id}
type={provider.id.toLowerCase()}
onClick={() => signIn(provider.id, { callbackUrl, multiAuth })}
text={`${text || 'Login'} with`}

View File

@ -223,6 +223,9 @@ export function MeDropdown ({ me, dropNavKey }) {
)
}
// this is the width of the 'switch account' button if no width is given
const SWITCH_ACCOUNT_BUTTON_WIDTH = '162px'
export function SignUpButton ({ className = 'py-0', width }) {
const router = useRouter()
const handleLogin = useCallback(async pathname => await router.push({
@ -233,7 +236,8 @@ export function SignUpButton ({ className = 'py-0', width }) {
return (
<Button
className={classNames('align-items-center ps-2 pe-3', className)}
style={{ borderWidth: '2px', width: width || '150px' }}
// 161px is the width of the 'switch account' button
style={{ borderWidth: '2px', width: width || SWITCH_ACCOUNT_BUTTON_WIDTH }}
id='signup'
onClick={() => handleLogin('/signup')}
>
@ -257,7 +261,7 @@ export default function LoginButton () {
<Button
className='align-items-center px-3 py-1'
id='login'
style={{ borderWidth: '2px', width: '150px' }}
style={{ borderWidth: '2px', width: SWITCH_ACCOUNT_BUTTON_WIDTH }}
variant='outline-grey-darkmode'
onClick={() => handleLogin('/login')}
>
@ -269,7 +273,7 @@ export default function LoginButton () {
function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const { removeLocalWallets } = useWallets()
const { multiAuthSignout } = useAccounts()
const { nextAccount } = useAccounts()
const router = useRouter()
return (
@ -285,9 +289,9 @@ function LogoutObstacle ({ onClose }) {
</Button>
<Button
onClick={async () => {
const switchSuccess = await multiAuthSignout()
// only signout if multiAuth did not find a next available account
if (switchSuccess) {
const next = await nextAccount()
// only signout if we did not find a next account
if (next) {
onClose()
// reload whatever page we're on to avoid any bugs
router.reload()
@ -344,7 +348,7 @@ function SwitchAccountButton ({ handleClose }) {
<Button
className='align-items-center px-3 py-1'
variant='outline-grey-darkmode'
style={{ borderWidth: '2px', width: '150px' }}
style={{ borderWidth: '2px', width: SWITCH_ACCOUNT_BUTTON_WIDTH }}
onClick={() => {
// login buttons rendered in offcanvas aren't wrapped inside <Dropdown>
// so we manually close the offcanvas in that case by passing down handleClose here

View File

@ -72,7 +72,10 @@ export default function PullToRefresh ({ children, className }) {
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<p className={`${styles.pullMessage}`} style={{ top: `${Math.max(-20, Math.min(-20 + pullDistance / 2, 5))}px` }}>
<p
className={`${styles.pullMessage}`}
style={{ opacity: pullDistance > 0 ? 1 : 0, top: `${Math.max(-20, Math.min(-20 + pullDistance / 2, 5))}px` }}
>
{pullMessage}
</p>
{children}

View File

@ -36,7 +36,7 @@ export default function Search ({ sub }) {
}
if (values.what === '' || values.what === 'all') delete values.what
if (values.sort === '' || values.sort === 'zaprank') delete values.sort
if (values.sort === '' || values.sort === 'relevance') delete values.sort
if (values.when === '' || values.when === 'forever') delete values.when
if (values.when !== 'custom') { delete values.from; delete values.to }
if (values.from && !values.to) return
@ -50,7 +50,7 @@ export default function Search ({ sub }) {
const filter = sub !== 'jobs'
const what = router.pathname.startsWith('/stackers') ? 'stackers' : router.query.what || 'all'
const sort = router.query.sort || 'zaprank'
const sort = router.query.sort || 'relevance'
const when = router.query.when || 'forever'
const whatItemOptions = useMemo(() => (['all', 'posts', 'comments', me ? 'bookmarks' : undefined, 'stackers'].filter(item => !!item)), [me])
@ -100,7 +100,7 @@ export default function Search ({ sub }) {
name='sort'
size='sm'
overrideValue={sort}
items={['zaprank', 'recent', 'comments', 'sats']}
items={['relevance', 'zaprank', 'recent', 'comments', 'sats']}
/>
for
<Select

View File

@ -94,8 +94,19 @@ export default function SubSelect ({ prependSubs, sub, onChange, size, appendSub
}
} else {
// we're currently on the home sub
// are we in a sub aware route?
if (router.pathname.startsWith('/~')) {
// if in /top/cowboys, /top/territories, or /top/stackers
// and a territory is selected, go to /~sub/top/posts/day
if (router.pathname.startsWith('/~/top/cowboys')) {
router.push(sub ? `/~${sub}/top/posts/day` : '/top/cowboys')
return
} else if (router.pathname.startsWith('/~/top/stackers')) {
router.push(sub ? `/~${sub}/top/posts/day` : 'top/stackers/day')
return
} else if (router.pathname.startsWith('/~/top/territories')) {
router.push(sub ? `/~${sub}/top/posts/day` : '/top/territories/day')
return
} else if (router.pathname.startsWith('/~')) {
// are we in a sub aware route?
// if we are, go to the same path but in the sub
asPath = `/~${sub}` + router.asPath
} else {

View File

@ -4,7 +4,7 @@ import Image from 'react-bootstrap/Image'
import Link from 'next/link'
import { useRouter } from 'next/router'
import Nav from 'react-bootstrap/Nav'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { Form, Input, SubmitButton } from './form'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import styles from './user-header.module.css'
@ -199,8 +199,14 @@ export function NymActionDropdown ({ user, className = 'ms-2' }) {
}
function HeaderNym ({ user, isMe }) {
const router = useRouter()
const [editting, setEditting] = useState(false)
// if route changes, reset editting state
useEffect(() => {
setEditting(false)
}, [router.asPath])
return editting
? <NymEdit user={user} setEditting={setEditting} />
: <NymView user={user} isMe={isMe} setEditting={setEditting} />

View File

@ -62,7 +62,11 @@ function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) {
const { deleteLogs } = useWalletLogManager(setLogs)
const toaster = useToast()
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
let prompt = 'Do you really want to delete all wallet logs?'
if (wallet) {
prompt = 'Do you really want to delete all logs of this wallet?'
}
return (
<div className='text-center'>
{prompt}

View File

@ -163,7 +163,7 @@ services:
- "CONNECT=localhost:4566"
cpu_shares: "${CPU_SHARES_LOW}"
opensearch:
image: opensearchproject/opensearch:2.12.0
image: opensearchproject/opensearch:2.17.0
container_name: opensearch
profiles:
- search
@ -203,7 +203,7 @@ services:
'
cpu_shares: "${CPU_SHARES_LOW}"
os-dashboard:
image: opensearchproject/opensearch-dashboards:2.12.0
image: opensearchproject/opensearch-dashboards:2.17.0
container_name: os-dashboard
restart: unless-stopped
profiles:

161
lib/auth.js Normal file
View File

@ -0,0 +1,161 @@
import { datePivot } from '@/lib/time'
import * as cookie from 'cookie'
import { NodeNextRequest } from 'next/dist/server/base-http/node'
import { encode as encodeJWT, decode as decodeJWT } from 'next-auth/jwt'
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
const userJwtRegexp = /^multi_auth\.\d+$/
const HTTPS = process.env.NODE_ENV === 'production'
const SESSION_COOKIE_NAME = HTTPS ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
const cookieOptions = (args) => ({
path: '/',
secure: process.env.NODE_ENV === 'production',
// httpOnly cookies by default
httpOnly: true,
sameSite: 'lax',
// default expiration for next-auth JWTs is in 30 days
expires: datePivot(new Date(), { days: 30 }),
...args
})
export function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
const httpOnlyOptions = cookieOptions()
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
// add JWT to **httpOnly** cookie
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, httpOnlyOptions))
// switch to user we just added
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', id, jsOptions))
let newMultiAuth = [{ id, name, photoId }]
if (req.cookies.multi_auth) {
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
// make sure we don't add duplicates
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
}
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), jsOptions))
}
export function switchSessionCookie (request) {
// switch next-auth session cookie with multi_auth cookie if cookie pointer present
// is there a cookie pointer?
const cookiePointerName = 'multi_auth.user-id'
const hasCookiePointer = !!request.cookies[cookiePointerName]
// is there a session?
const hasSession = !!request.cookies[SESSION_COOKIE_NAME]
if (!hasCookiePointer || !hasSession) {
// no session or no cookie pointer. do nothing.
return request
}
const userId = request.cookies[cookiePointerName]
if (userId === 'anonymous') {
// user switched to anon. only delete session cookie.
delete request.cookies[SESSION_COOKIE_NAME]
return request
}
const userJWT = request.cookies[`multi_auth.${userId}`]
if (!userJWT) {
// no JWT for account switching found
return request
}
if (userJWT) {
// use JWT found in cookie pointed to by cookie pointer
request.cookies[SESSION_COOKIE_NAME] = userJWT
return request
}
return request
}
export function checkMultiAuthCookies (req, res) {
if (!req.cookies.multi_auth || !req.cookies['multi_auth.user-id']) {
return false
}
const accounts = b64Decode(req.cookies.multi_auth)
for (const account of accounts) {
if (!req.cookies[`multi_auth.${account.id}`]) {
return false
}
}
return true
}
export function resetMultiAuthCookies (req, res) {
const httpOnlyOptions = cookieOptions({ expires: 0, maxAge: 0 })
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
if ('multi_auth' in req.cookies) res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', '', jsOptions))
if ('multi_auth.user-id' in req.cookies) res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', '', jsOptions))
for (const key of Object.keys(req.cookies)) {
// reset all user JWTs
if (userJwtRegexp.test(key)) {
res.appendHeader('Set-Cookie', cookie.serialize(key, '', httpOnlyOptions))
}
}
}
export async function refreshMultiAuthCookies (req, res) {
const httpOnlyOptions = cookieOptions()
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
const refreshCookie = (name) => {
res.appendHeader('Set-Cookie', cookie.serialize(name, req.cookies[name], jsOptions))
}
const refreshToken = async (token) => {
const secret = process.env.NEXTAUTH_SECRET
return await encodeJWT({
token: await decodeJWT({ token, secret }),
secret
})
}
const isAnon = req.cookies['multi_auth.user-id'] === 'anonymous'
for (const [key, value] of Object.entries(req.cookies)) {
// only refresh session cookie manually if we switched to anon since else it's already handled by next-auth
if (key === SESSION_COOKIE_NAME && !isAnon) continue
if (!key.startsWith('multi_auth') && key !== SESSION_COOKIE_NAME) continue
if (userJwtRegexp.test(key) || key === SESSION_COOKIE_NAME) {
const oldToken = value
const newToken = await refreshToken(oldToken)
res.appendHeader('Set-Cookie', cookie.serialize(key, newToken, httpOnlyOptions))
continue
}
refreshCookie(key)
}
}
export async function multiAuthMiddleware (req, res) {
if (!req.cookies) {
// required to properly access parsed cookies via req.cookies and not unparsed via req.headers.cookie
req = new NodeNextRequest(req)
}
const ok = checkMultiAuthCookies(req, res)
if (!ok) {
resetMultiAuthCookies(req, res)
return switchSessionCookie(req)
}
await refreshMultiAuthCookies(req, res)
return switchSessionCookie(req)
}

View File

@ -23,11 +23,11 @@ export function timeSince (timeStamp) {
}
export function datePivot (date,
{ years = 0, months = 0, days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }) {
{ years = 0, months = 0, weeks = 0, days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }) {
return new Date(
date.getFullYear() + years,
date.getMonth() + months,
date.getDate() + days,
date.getDate() + days + weeks * 7,
date.getHours() + hours,
date.getMinutes() + minutes,
date.getSeconds() + seconds,

View File

@ -194,6 +194,21 @@ module.exports = withPlausibleProxy()({
source: '/top/cowboys/:when',
destination: '/top/cowboys',
permanent: true
},
{
source: '/~:sub/top/cowboys',
destination: '/top/cowboys',
permanent: true
},
{
source: '/~:sub/top/stackers/:when*',
destination: '/top/stackers/:when*',
permanent: true
},
{
source: '/~:sub/top/territories/:when*',
destination: '/top/territories/:when*',
permanent: true
}
]
},

View File

@ -8,13 +8,13 @@ import prisma from '@/api/models'
import nodemailer from 'nodemailer'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { getToken, encode as encodeJWT } from 'next-auth/jwt'
import { datePivot } from '@/lib/time'
import { schnorr } from '@noble/curves/secp256k1'
import { notifyReferral } from '@/lib/webPush'
import { hashEmail } from '@/lib/crypto'
import * as cookie from 'cookie'
import { multiAuthMiddleware } from '@/pages/api/graphql'
import { multiAuthMiddleware, setMultiAuthCookies } from '@/lib/auth'
import { BECH32_CHARSET } from '@/lib/constants'
import { NodeNextRequest } from 'next/dist/server/base-http/node'
import * as cookie from 'cookie'
/**
* Stores userIds in user table
@ -94,6 +94,8 @@ function getCallbacks (req, res) {
*/
async jwt ({ token, user, account, profile, isNewUser }) {
if (user) {
// reset signup cookie if any
res.appendHeader('Set-Cookie', cookie.serialize('signin', '', { path: '/', expires: 0, maxAge: 0 }))
// token won't have an id on it for new logins, we add it
// note: token is what's kept in the jwt
token.id = Number(user.id)
@ -124,8 +126,8 @@ function getCallbacks (req, res) {
token.sub = Number(token.id)
}
// add multi_auth cookie for user that just logged in
if (user && req && res) {
// add multi_auth cookie for user that just logged in
const secret = process.env.NEXTAUTH_SECRET
const jwt = await encodeJWT({ token, secret })
const me = await prisma.user.findUnique({ where: { id: token.id } })
@ -144,37 +146,6 @@ function getCallbacks (req, res) {
}
}
function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
// default expiration for next-auth JWTs is in 1 month
const expiresAt = datePivot(new Date(), { months: 1 })
const secure = process.env.NODE_ENV === 'production'
const cookieOptions = {
path: '/',
httpOnly: true,
secure,
sameSite: 'lax',
expires: expiresAt
}
// add JWT to **httpOnly** cookie
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, cookieOptions))
// switch to user we just added
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', id, { ...cookieOptions, httpOnly: false }))
let newMultiAuth = [{ id, name, photoId }]
if (req.cookies.multi_auth) {
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
// make sure we don't add duplicates
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
}
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), { ...cookieOptions, httpOnly: false }))
}
async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
const { k1, pubkey } = credentials
@ -194,7 +165,7 @@ async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } })
// make following code aware of cookie pointer for account switching
req = multiAuthMiddleware(req)
req = await multiAuthMiddleware(req, res)
// token will be undefined if we're not logged in at all or if we switched to anon
const token = await getToken({ req })
if (!user) {
@ -205,7 +176,8 @@ async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
if (token?.id && !multiAuth) {
user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } })
} else {
// we're not logged in: create new user with that pubkey
// create a new user only if we're trying to sign up
if (new NodeNextRequest(req).cookies.signin) return null
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } })
}
}
@ -314,6 +286,7 @@ export const getAuthOptions = (req, res) => ({
adapter: {
...PrismaAdapter(prisma),
createUser: data => {
if (req.cookies.signin) return null
// replace email with email hash in new user payload
if (data.email) {
const { email } = data
@ -754,7 +727,7 @@ const newUserHtml = ({ url, token, 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:14px;line-height:20px;text-align:left;color:#000000;">Stacker News is like Reddit or Hacker News, but it <b>pays you Bitcoin</b>. Instead of giving posts or comments upvotes, Stacker News users (aka stackers) send you small amounts of Bitcoin called sats.</div>
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Stacker News is like Reddit or Hacker News, but it <b>pays you Bitcoin</b>. Instead of giving posts or comments "upvotes," Stacker News users (aka stackers) send you small amounts of Bitcoin called sats.</div>
</td>
</tr>
<tr>
@ -769,7 +742,7 @@ const newUserHtml = ({ url, token, 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;">If youre not sure what to share, <a href="${dailyUrl}"><b><i>click here to introduce yourself to the community</i></b></a> with a comment on the daily discussion thread.</div>
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If you're not sure what to share, <a href="${dailyUrl}"><b><i>click here to introduce yourself to the community</i></b></a> with a comment on the daily discussion thread.</div>
</td>
</tr>
<tr>
@ -779,7 +752,7 @@ const newUserHtml = ({ url, token, 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;">If anything isnt clear, comment on the FAQ post and well answer your question.</div>
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If anything isn't clear, comment on the FAQ post and we'll answer your question.</div>
</td>
</tr>
<tr>

View File

@ -11,7 +11,7 @@ import {
ApolloServerPluginLandingPageLocalDefault,
ApolloServerPluginLandingPageProductionDefault
} from '@apollo/server/plugin/landingPage/default'
import { NodeNextRequest } from 'next/dist/server/base-http/node'
import { multiAuthMiddleware } from '@/lib/auth'
const apolloServer = new ApolloServer({
typeDefs,
@ -68,7 +68,7 @@ export default startServerAndCreateNextHandler(apolloServer, {
session = { user: { ...sessionFields, apiKey: true } }
}
} else {
req = multiAuthMiddleware(req)
req = await multiAuthMiddleware(req, res)
session = await getServerSession(req, res, getAuthOptions(req))
}
return {
@ -82,49 +82,3 @@ export default startServerAndCreateNextHandler(apolloServer, {
}
}
})
export function multiAuthMiddleware (request) {
// switch next-auth session cookie with multi_auth cookie if cookie pointer present
if (!request.cookies) {
// required to properly access parsed cookies via request.cookies
// and not unparsed via request.headers.cookie
request = new NodeNextRequest(request)
}
// is there a cookie pointer?
const cookiePointerName = 'multi_auth.user-id'
const hasCookiePointer = !!request.cookies[cookiePointerName]
const secure = process.env.NODE_ENV === 'production'
// is there a session?
const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
const hasSession = !!request.cookies[sessionCookieName]
if (!hasCookiePointer || !hasSession) {
// no session or no cookie pointer. do nothing.
return request
}
const userId = request.cookies[cookiePointerName]
if (userId === 'anonymous') {
// user switched to anon. only delete session cookie.
delete request.cookies[sessionCookieName]
return request
}
const userJWT = request.cookies[`multi_auth.${userId}`]
if (!userJWT) {
// no JWT for account switching found
return request
}
if (userJWT) {
// use JWT found in cookie pointed to by cookie pointer
request.cookies[sessionCookieName] = userJWT
return request
}
return request
}

View File

@ -81,6 +81,7 @@ export default function LoginPage (props) {
<Login
Footer={() => <LoginFooter callbackUrl={props.callbackUrl} />}
Header={() => <LoginHeader />}
signin
{...props}
/>
</StaticLayout>

View File

@ -131,8 +131,6 @@ function Enabled ({ setVaultKey, clearVault }) {
placeholder=''
required
autoFocus
as='textarea'
rows={3}
qr
/>
<div className='mt-3'>

View File

@ -0,0 +1,109 @@
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "subWeightedDownVotes" FLOAT NOT NULL DEFAULT 0,
ADD COLUMN "subWeightedVotes" FLOAT NOT NULL DEFAULT 0;
CREATE INDEX "Item.sumSubVotes_index" ON "Item"(("subWeightedVotes" - "subWeightedDownVotes"));
-- CreateTable
CREATE TABLE "UserSubTrust" (
"subName" CITEXT NOT NULL,
"userId" INTEGER NOT NULL,
"zapPostTrust" DOUBLE PRECISION NOT NULL DEFAULT 0,
"subZapPostTrust" DOUBLE PRECISION NOT NULL DEFAULT 0,
"zapCommentTrust" DOUBLE PRECISION NOT NULL DEFAULT 0,
"subZapCommentTrust" DOUBLE PRECISION NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserSubTrust_pkey" PRIMARY KEY ("userId","subName")
);
-- AddForeignKey
ALTER TABLE "UserSubTrust" ADD CONSTRAINT "UserSubTrust_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserSubTrust" ADD CONSTRAINT "UserSubTrust_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
-- UserSubTrust is NOT populated, so this is a no-op ... but useful having it written out for migrating manually on deployment
UPDATE "Item"
SET "subWeightedVotes" = subquery."subWeightedVotes",
"subWeightedDownVotes" = subquery."subWeightedDownVotes",
"weightedVotes" = subquery."weightedVotes",
"weightedDownVotes" = subquery."weightedDownVotes"
FROM (
WITH sub_votes AS (
SELECT "ItemAct"."itemId",
CASE WHEN (SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('TIP', 'FEE')))::FLOAT > 0
THEN COALESCE(
LOG(
(SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('TIP', 'FEE')))::FLOAT / 1000
) * CASE
WHEN "Item"."parentId" IS NULL
THEN "UserSubTrust"."subZapPostTrust"
ELSE "UserSubTrust"."subZapCommentTrust"
END, 0)
ELSE 0
END AS "subWeightedVotes",
CASE WHEN (SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('TIP', 'FEE')))::FLOAT > 0
THEN COALESCE(
LOG(
(SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('TIP', 'FEE')))::FLOAT / 1000
) * CASE
WHEN "Item"."parentId" IS NULL
THEN "UserSubTrust"."zapPostTrust"
ELSE "UserSubTrust"."zapCommentTrust"
END, 0)
ELSE 0
END AS "weightedVotes",
CASE WHEN (SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('DONT_LIKE_THIS')))::FLOAT > 0
THEN COALESCE(
LOG(
(SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('DONT_LIKE_THIS')))::FLOAT / 1000
) * CASE
WHEN "Item"."parentId" IS NULL
THEN "UserSubTrust"."subZapPostTrust"
ELSE "UserSubTrust"."subZapCommentTrust"
END, 0)
ELSE 0
END AS "subWeightedDownVotes",
CASE WHEN (SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('DONT_LIKE_THIS')))::FLOAT > 0
THEN COALESCE(
LOG(
(SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('DONT_LIKE_THIS')))::FLOAT / 1000
) * CASE
WHEN "Item"."parentId" IS NULL
THEN "UserSubTrust"."zapPostTrust"
ELSE "UserSubTrust"."zapCommentTrust"
END, 0)
ELSE 0
END AS "weightedDownVotes"
FROM "ItemAct"
JOIN "UserSubTrust" ON "ItemAct"."userId" = "UserSubTrust"."userId"
JOIN "Item" ON "Item".id = "ItemAct"."itemId"
AND "UserSubTrust"."subName" = "Item"."subName"
AND "Item"."userId" <> "ItemAct"."userId"
WHERE "ItemAct".act IN ('TIP', 'FEE', 'DONT_LIKE_THIS')
GROUP BY "ItemAct"."itemId", "ItemAct"."userId", "UserSubTrust"."subZapPostTrust",
"UserSubTrust"."subZapCommentTrust", "UserSubTrust"."zapPostTrust", "UserSubTrust"."zapCommentTrust",
"Item"."parentId"
)
SELECT "itemId", SUM("subWeightedVotes") AS "subWeightedVotes", SUM("subWeightedDownVotes") AS "subWeightedDownVotes",
SUM("weightedVotes") AS "weightedVotes", SUM("weightedDownVotes") AS "weightedDownVotes"
FROM sub_votes
GROUP BY "itemId"
) AS subquery
WHERE "Item".id = subquery."itemId";
CREATE MATERIALIZED VIEW IF NOT EXISTS hot_score_view AS
SELECT id,
("Item"."weightedVotes" - "Item"."weightedDownVotes" + ("Item"."weightedComments"*0.25) + ("Item".boost / 5000))
/ POWER(GREATEST(3, EXTRACT(EPOCH FROM (now() - "Item".created_at))/3600), 1.1) AS hot_score,
("Item"."subWeightedVotes" - "Item"."subWeightedDownVotes" + ("Item"."weightedComments"*0.25) + ("Item".boost / 5000))
/ POWER(GREATEST(3, EXTRACT(EPOCH FROM (now() - "Item".created_at))/3600), 1.1) AS sub_hot_score
FROM "Item"
WHERE "Item"."weightedVotes" > 0 OR "Item"."weightedDownVotes" > 0 OR "Item"."subWeightedVotes" > 0
OR "Item"."subWeightedDownVotes" > 0 OR "Item"."weightedComments" > 0 OR "Item".boost > 0;
CREATE UNIQUE INDEX IF NOT EXISTS hot_score_view_id_idx ON hot_score_view(id);
CREATE INDEX IF NOT EXISTS hot_score_view_hot_score_idx ON hot_score_view(hot_score DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS hot_score_view_sub_hot_score_idx ON hot_score_view(sub_hot_score DESC NULLS LAST);

View File

@ -0,0 +1,224 @@
-- 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 '
|| ' FROM "Item" '
|| ' 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 '
|| ' FROM "Item" '
|| ' JOIN base b ON "Item"."parentId" = b.id '
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".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", '
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|| '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 hot_score_view g ON g.id = "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 cowboy credits
CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me(_item_id int, _global_seed int, _me_id 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'
|| ' 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", '
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|| ' FROM "Item" '
|| ' JOIN users ON users.id = "Item"."userId" '
|| ' LEFT JOIN "Mute" ON "Mute"."muterId" = $5 AND "Mute"."mutedId" = "Item"."userId"'
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $5 AND "Bookmark"."itemId" = "Item".id '
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $5 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" = $5 '
|| ' AND "ItemAct"."itemId" = "Item".id '
|| ' GROUP BY "ItemAct"."itemId" '
|| ' ) "ItemAct" ON true '
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|| ' WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) ' || _where || ' '
USING _item_id, _level, _where, _order_by, _me_id, _global_seed;
EXECUTE ''
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|| 'FROM ( '
|| ' SELECT "Item".*, item_comments_zaprank_with_me("Item".id, $6, $5, $2 - 1, $3, $4) AS comments '
|| ' FROM t_item "Item" '
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by
|| ' ) sub'
INTO result USING _item_id, _level, _where, _order_by, _me_id, _global_seed;
RETURN result;
END
$$;
CREATE OR REPLACE FUNCTION item_comments(_item_id 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'
|| ' 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, '
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|| ' FROM "Item" '
|| ' JOIN users ON users.id = "Item"."userId" '
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|| ' WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) ' || _where
USING _item_id, _level, _where, _order_by;
EXECUTE ''
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|| 'FROM ( '
|| ' SELECT "Item".*, item_comments("Item".id, $2 - 1, $3, $4) AS comments '
|| ' FROM t_item "Item"'
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by
|| ' ) sub'
INTO result USING _item_id, _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 '
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".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, '
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|| 'FROM base "Item" '
|| 'JOIN users ON users.id = "Item"."userId" '
|| 'LEFT JOIN hot_score_view g ON g.id = "Item".id '
|| '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 @@
CREATE INDEX IF NOT EXISTS hot_score_view_hot_score_no_nulls_idx ON hot_score_view(hot_score DESC);
CREATE INDEX IF NOT EXISTS hot_score_view_sub_hot_score_no_nulls_idx ON hot_score_view(sub_hot_score DESC);

View File

@ -0,0 +1,132 @@
-- 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 '
|| ' FROM "Item" '
|| ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".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 '
|| ' FROM "Item" '
|| ' JOIN base b ON "Item"."parentId" = b.id '
|| ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".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", '
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|| '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 hot_score_view g ON g.id = "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
$$;
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" '
|| ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
|| ' 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 '
|| ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".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, '
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|| 'FROM base "Item" '
|| 'JOIN users ON users.id = "Item"."userId" '
|| 'LEFT JOIN hot_score_view g ON g.id = "Item".id '
|| '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

@ -148,6 +148,7 @@ model User {
directReceive Boolean @default(true)
DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
UserSubTrust UserSubTrust[]
@@index([photoId])
@@index([createdAt], map: "users.created_at_index")
@ -184,6 +185,21 @@ model OneDayReferral {
@@index([type, typeId])
}
model UserSubTrust {
subName String @db.Citext
userId Int
zapPostTrust Float @default(0)
subZapPostTrust Float @default(0)
zapCommentTrust Float @default(0)
subZapCommentTrust Float @default(0)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade)
@@id([userId, subName])
}
enum WalletType {
LIGHTNING_ADDRESS
LND
@ -498,85 +514,87 @@ model Message {
/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info.
model Item {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
title String?
text String?
url String?
userId Int
parentId Int?
path Unsupported("ltree")?
pinId Int?
latitude Float?
location String?
longitude Float?
maxBid Int?
maxSalary Int?
minSalary Int?
remote Boolean?
subName String? @db.Citext
statusUpdatedAt DateTime?
status Status @default(ACTIVE)
company String?
weightedVotes Float @default(0)
boost Int @default(0)
oldBoost Int @default(0)
pollCost Int?
paidImgLink Boolean @default(false)
commentMsats BigInt @default(0)
commentMcredits BigInt @default(0)
lastCommentAt DateTime?
lastZapAt DateTime?
ncomments Int @default(0)
nDirectComments Int @default(0)
msats BigInt @default(0)
mcredits BigInt @default(0)
cost Int @default(0)
weightedDownVotes Float @default(0)
bio Boolean @default(false)
freebie Boolean @default(false)
deletedAt DateTime?
otsFile Bytes?
otsHash String?
imgproxyUrls Json?
bounty Int?
noteId String? @unique(map: "Item.noteId_unique")
rootId Int?
bountyPaidTo Int[]
upvotes Int @default(0)
weightedComments Float @default(0)
Bookmark Bookmark[]
parent Item? @relation("ParentChildren", fields: [parentId], references: [id])
children Item[] @relation("ParentChildren")
pin Pin? @relation(fields: [pinId], references: [id])
root Item? @relation("RootDescendant", fields: [rootId], references: [id])
descendants Item[] @relation("RootDescendant")
sub Sub? @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade)
itemActs ItemAct[]
mentions Mention[]
itemReferrers ItemMention[] @relation("referrer")
itemReferees ItemMention[] @relation("referee")
pollOptions PollOption[]
PollVote PollVote[]
threadSubscriptions ThreadSubscription[]
User User[]
itemForwards ItemForward[]
itemUploads ItemUpload[]
uploadId Int?
invoiceId Int?
invoiceActionState InvoiceActionState?
invoicePaidAt DateTime?
outlawed Boolean @default(false)
apiKey Boolean @default(false)
pollExpiresAt DateTime?
Ancestors Reply[] @relation("AncestorReplyItem")
Replies Reply[]
Reminder Reminder[]
invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull)
PollBlindVote PollBlindVote[]
ItemUserAgg ItemUserAgg[]
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
title String?
text String?
url String?
userId Int
parentId Int?
path Unsupported("ltree")?
pinId Int?
latitude Float?
location String?
longitude Float?
maxBid Int?
maxSalary Int?
minSalary Int?
remote Boolean?
subName String? @db.Citext
statusUpdatedAt DateTime?
status Status @default(ACTIVE)
company String?
weightedVotes Float @default(0)
subWeightedVotes Float @default(0)
boost Int @default(0)
oldBoost Int @default(0)
pollCost Int?
paidImgLink Boolean @default(false)
commentMsats BigInt @default(0)
commentMcredits BigInt @default(0)
lastCommentAt DateTime?
lastZapAt DateTime?
ncomments Int @default(0)
nDirectComments Int @default(0)
msats BigInt @default(0)
mcredits BigInt @default(0)
cost Int @default(0)
weightedDownVotes Float @default(0)
subWeightedDownVotes Float @default(0)
bio Boolean @default(false)
freebie Boolean @default(false)
deletedAt DateTime?
otsFile Bytes?
otsHash String?
imgproxyUrls Json?
bounty Int?
noteId String? @unique(map: "Item.noteId_unique")
rootId Int?
bountyPaidTo Int[]
upvotes Int @default(0)
weightedComments Float @default(0)
Bookmark Bookmark[]
parent Item? @relation("ParentChildren", fields: [parentId], references: [id])
children Item[] @relation("ParentChildren")
pin Pin? @relation(fields: [pinId], references: [id])
root Item? @relation("RootDescendant", fields: [rootId], references: [id])
descendants Item[] @relation("RootDescendant")
sub Sub? @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade)
itemActs ItemAct[]
mentions Mention[]
itemReferrers ItemMention[] @relation("referrer")
itemReferees ItemMention[] @relation("referee")
pollOptions PollOption[]
PollVote PollVote[]
threadSubscriptions ThreadSubscription[]
User User[]
itemForwards ItemForward[]
itemUploads ItemUpload[]
uploadId Int?
invoiceId Int?
invoiceActionState InvoiceActionState?
invoicePaidAt DateTime?
outlawed Boolean @default(false)
apiKey Boolean @default(false)
pollExpiresAt DateTime?
Ancestors Reply[] @relation("AncestorReplyItem")
Replies Reply[]
Reminder Reminder[]
invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull)
PollBlindVote PollBlindVote[]
ItemUserAgg ItemUserAgg[]
@@index([uploadId])
@@index([lastZapAt])
@ -760,6 +778,7 @@ model Sub {
MuteSub MuteSub[]
SubSubscription SubSubscription[]
TerritoryTransfer TerritoryTransfer[]
UserSubTrust UserSubTrust[]
@@index([parentName])
@@index([createdAt])

View File

@ -33,7 +33,7 @@ export async function createInvoice (
out: false
})
let hostname = url.replace(/^https?:\/\//, '')
let hostname = url.replace(/^https?:\/\//, '').replace(/\/+$/, '')
const agent = getAgent({ hostname })
if (process.env.NODE_ENV !== 'production' && hostname.startsWith('localhost:')) {

View File

@ -1,5 +1,6 @@
import { fetchWithTimeout } from '@/lib/fetch'
import { msatsToSats } from '@/lib/format'
import { getAgent } from '@/lib/proxy'
import { assertContentTypeJson, assertResponseOk } from '@/lib/url'
export * from '@/wallets/phoenixd'
@ -27,9 +28,13 @@ export async function createInvoice (
body.append('description', description)
body.append('amountSat', msatsToSats(msats))
const res = await fetchWithTimeout(url + path, {
const hostname = url.replace(/^https?:\/\//, '').replace(/\/+$/, '')
const agent = getAgent({ hostname })
const res = await fetchWithTimeout(`${agent.protocol}//${hostname}${path}`, {
method: 'POST',
headers,
agent,
body,
signal
})

View File

@ -14,6 +14,7 @@ export async function expireBoost ({ data: { id }, models }) {
FROM "ItemAct"
WHERE act = 'BOOST'
AND "itemId" = ${Number(id)}::INTEGER
AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID')
)
UPDATE "Item"
SET boost = COALESCE(boost.cur_msats, 0) / 1000, "oldBoost" = COALESCE(boost.old_msats, 0) / 1000

View File

@ -1,38 +1,89 @@
import * as math from 'mathjs'
import { USER_ID, SN_ADMIN_IDS } from '@/lib/constants'
import { USER_ID } from '@/lib/constants'
import { Prisma } from '@prisma/client'
import { initialTrust, GLOBAL_SEEDS } from '@/api/paidAction/lib/territory'
export async function trust ({ boss, models }) {
try {
console.time('trust')
console.timeLog('trust', 'getting graph')
const graph = await getGraph(models)
console.timeLog('trust', 'computing trust')
const [vGlobal, mPersonal] = await trustGivenGraph(graph)
console.timeLog('trust', 'storing trust')
await storeTrust(models, graph, vGlobal, mPersonal)
} finally {
console.timeEnd('trust')
}
}
const MAX_DEPTH = 10
const MAX_DEPTH = 40
const MAX_TRUST = 1
const MIN_SUCCESS = 1
const MIN_SUCCESS = 0
// https://en.wikipedia.org/wiki/Normal_distribution#Quantile_function
const Z_CONFIDENCE = 6.109410204869 // 99.9999999% confidence
const GLOBAL_ROOT = 616
const SEED_WEIGHT = 1.0
const SEED_WEIGHT = 0.83
const AGAINST_MSAT_MIN = 1000
const MSAT_MIN = 20001 // 20001 is the minimum for a tip to be counted in trust
const SIG_DIFF = 0.1 // need to differ by at least 10 percent
const MSAT_MIN = 1001 // 20001 is the minimum for a tip to be counted in trust
const INDEPENDENCE_THRESHOLD = 50 // how many zappers are needed to consider a sub independent
const IRRELEVANT_CUMULATIVE_TRUST = 0.001 // if a user has less than this amount of cumulative trust, they are irrelevant
// for each subName, we'll need to get two graphs
// one for comments and one for posts
// then we'll need to do two trust calculations on each graph
// one with global seeds and one with subName seeds
export async function trust ({ boss, models }) {
console.time('trust')
const territories = await models.sub.findMany({
where: {
status: 'ACTIVE'
}
})
for (const territory of territories) {
const seeds = GLOBAL_SEEDS.includes(territory.userId) ? GLOBAL_SEEDS : GLOBAL_SEEDS.concat(territory.userId)
try {
console.timeLog('trust', `getting post graph for ${territory.name}`)
const postGraph = await getGraph(models, territory.name, true, seeds)
console.timeLog('trust', `getting comment graph for ${territory.name}`)
const commentGraph = await getGraph(models, territory.name, false, seeds)
console.timeLog('trust', `computing global post trust for ${territory.name}`)
const vGlobalPost = await trustGivenGraph(postGraph)
console.timeLog('trust', `computing global comment trust for ${territory.name}`)
const vGlobalComment = await trustGivenGraph(commentGraph)
console.timeLog('trust', `computing sub post trust for ${territory.name}`)
const vSubPost = await trustGivenGraph(postGraph, postGraph.length > INDEPENDENCE_THRESHOLD ? [territory.userId] : seeds)
console.timeLog('trust', `computing sub comment trust for ${territory.name}`)
const vSubComment = await trustGivenGraph(commentGraph, commentGraph.length > INDEPENDENCE_THRESHOLD ? [territory.userId] : seeds)
console.timeLog('trust', `storing trust for ${territory.name}`)
let results = reduceVectors(territory.name, {
zapPostTrust: {
graph: postGraph,
vector: vGlobalPost
},
subZapPostTrust: {
graph: postGraph,
vector: vSubPost
},
zapCommentTrust: {
graph: commentGraph,
vector: vGlobalComment
},
subZapCommentTrust: {
graph: commentGraph,
vector: vSubComment
}
})
if (results.length === 0) {
console.timeLog('trust', `no results for ${territory.name} - adding seeds`)
results = initialTrust({ name: territory.name, userId: territory.userId })
}
await storeTrust(models, territory.name, results)
} catch (e) {
console.error(`error computing trust for ${territory.name}:`, e)
} finally {
console.timeLog('trust', `finished computing trust for ${territory.name}`)
}
}
console.timeEnd('trust')
}
/*
Given a graph and start this function returns an object where
the keys are the node id and their value is the trust of that node
*/
function trustGivenGraph (graph) {
// I'm going to need to send subName, and multiply by a vector instead of a matrix
function trustGivenGraph (graph, seeds = GLOBAL_SEEDS) {
console.timeLog('trust', `creating matrix of size ${graph.length} x ${graph.length}`)
// empty matrix of proper size nstackers x nstackers
let mat = math.zeros(graph.length, graph.length, 'sparse')
const mat = math.zeros(graph.length, graph.length, 'sparse')
// create a map of user id to position in matrix
const posByUserId = {}
@ -54,54 +105,57 @@ function trustGivenGraph (graph) {
// perform random walk over trust matrix
// the resulting matrix columns represent the trust a user (col) has for each other user (rows)
// XXX this scales N^3 and mathjs is slow
let matT = math.transpose(mat)
const original = matT.clone()
for (let i = 0; i < MAX_DEPTH; i++) {
console.timeLog('trust', `matrix multiply ${i}`)
matT = math.multiply(original, matT)
matT = math.add(math.multiply(1 - SEED_WEIGHT, matT), math.multiply(SEED_WEIGHT, original))
const matT = math.transpose(mat)
const vTrust = math.zeros(graph.length)
for (const seed of seeds) {
vTrust.set([posByUserId[seed], 0], 1.0 / seeds.length)
}
let result = vTrust.clone()
console.timeLog('trust', 'matrix multiply')
for (let i = 0; i < MAX_DEPTH; i++) {
result = math.multiply(matT, result)
result = math.add(math.multiply(1 - SEED_WEIGHT, result), math.multiply(SEED_WEIGHT, vTrust))
}
result = math.squeeze(result)
console.timeLog('trust', 'transforming result')
const seedIdxs = SN_ADMIN_IDS.map(id => posByUserId[id])
const isOutlier = (fromIdx, idx) => [...seedIdxs, fromIdx].includes(idx)
const sqapply = (mat, fn) => {
let idx = 0
return math.squeeze(math.apply(mat, 1, d => {
const filtered = math.filter(d, (val, fidx) => {
return val !== 0 && !isOutlier(idx, fidx[0])
})
idx++
if (filtered.length === 0) return 0
return fn(filtered)
}))
const seedIdxs = seeds.map(id => posByUserId[id])
const filterZeroAndSeed = (val, idx) => {
return val !== 0 && !seedIdxs.includes(idx[0])
}
const filterSeed = (val, idx) => {
return !seedIdxs.includes(idx[0])
}
const sqapply = (vec, filterFn, fn) => {
// if the vector is smaller than the seeds, don't filter
const filtered = vec.size()[0] > seeds.length ? math.filter(vec, filterFn) : vec
if (filtered.size()[0] === 0) return 0
return fn(filtered)
}
console.timeLog('trust', 'normalizing')
console.timeLog('trust', 'stats')
mat = math.transpose(matT)
const std = sqapply(mat, math.std) // math.squeeze(math.std(mat, 1))
const mean = sqapply(mat, math.mean) // math.squeeze(math.mean(mat, 1))
const zscore = math.map(mat, (val, idx) => {
const zstd = math.subset(std, math.index(idx[0], 0))
const zmean = math.subset(mean, math.index(idx[0], 0))
return zstd ? (val - zmean) / zstd : 0
const std = sqapply(result, filterZeroAndSeed, math.std) // math.squeeze(math.std(mat, 1))
const mean = sqapply(result, filterZeroAndSeed, math.mean) // math.squeeze(math.mean(mat, 1))
console.timeLog('trust', 'std', std)
console.timeLog('trust', 'mean', mean)
const zscore = math.map(result, (val) => {
if (std === 0) return 0
return (val - mean) / std
})
console.timeLog('trust', 'minmax')
const min = sqapply(zscore, math.min) // math.squeeze(math.min(zscore, 1))
const max = sqapply(zscore, math.max) // math.squeeze(math.max(zscore, 1))
const mPersonal = math.map(zscore, (val, idx) => {
const zmin = math.subset(min, math.index(idx[0], 0))
const zmax = math.subset(max, math.index(idx[0], 0))
const zrange = zmax - zmin
if (val > zmax) return MAX_TRUST
return zrange ? (val - zmin) / zrange : 0
const min = sqapply(zscore, filterSeed, math.min) // math.squeeze(math.min(zscore, 1))
const max = sqapply(zscore, filterSeed, math.max) // math.squeeze(math.max(zscore, 1))
console.timeLog('trust', 'min', min)
console.timeLog('trust', 'max', max)
const normalized = math.map(zscore, (val) => {
const zrange = max - min
if (val > max) return MAX_TRUST
return zrange ? (val - min) / zrange : 0
})
const vGlobal = math.squeeze(math.row(mPersonal, posByUserId[GLOBAL_ROOT]))
return [vGlobal, mPersonal]
return normalized
}
/*
@ -111,23 +165,31 @@ function trustGivenGraph (graph) {
...
]
*/
async function getGraph (models) {
// I'm going to want to send subName to this function
// and whether it's for comments or posts
async function getGraph (models, subName, postTrust = true, seeds = GLOBAL_SEEDS) {
return await models.$queryRaw`
SELECT id, json_agg(json_build_object(
'node', oid,
'trust', CASE WHEN total_trust > 0 THEN trust / total_trust::float ELSE 0 END)) AS hops
FROM (
WITH user_votes AS (
SELECT "ItemAct"."userId" AS user_id, users.name AS name, "ItemAct"."itemId" AS item_id, min("ItemAct".created_at) AS act_at,
SELECT "ItemAct"."userId" AS user_id, users.name AS name, "ItemAct"."itemId" AS item_id, max("ItemAct".created_at) AS act_at,
users.created_at AS user_at, "ItemAct".act = 'DONT_LIKE_THIS' AS against,
count(*) OVER (partition by "ItemAct"."userId") AS user_vote_count,
sum("ItemAct".msats) as user_msats
FROM "ItemAct"
JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS')
AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId"
AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId"
AND ${postTrust
? Prisma.sql`"Item"."parentId" IS NULL AND "Item"."subName" = ${subName}::TEXT`
: Prisma.sql`
"Item"."parentId" IS NOT NULL
JOIN "Item" root ON "Item"."rootId" = root.id AND root."subName" = ${subName}::TEXT`
}
JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${USER_ID.anon}
WHERE "ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'
GROUP BY user_id, name, item_id, user_at, against
WHERE ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
GROUP BY user_id, users.name, item_id, user_at, against
HAVING CASE WHEN
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}
ELSE sum("ItemAct".msats) > ${MSAT_MIN} END
@ -136,7 +198,7 @@ async function getGraph (models) {
SELECT a.user_id AS a_id, b.user_id AS b_id,
sum(CASE WHEN b.user_msats > a.user_msats THEN a.user_msats / b.user_msats::FLOAT ELSE b.user_msats / a.user_msats::FLOAT END) FILTER(WHERE a.act_at > b.act_at AND a.against = b.against) AS before,
sum(CASE WHEN b.user_msats > a.user_msats THEN a.user_msats / b.user_msats::FLOAT ELSE b.user_msats / a.user_msats::FLOAT END) FILTER(WHERE b.act_at > a.act_at AND a.against = b.against) AS after,
sum(log(1 + a.user_msats / 10000::float) + log(1 + b.user_msats / 10000::float)) FILTER(WHERE a.against <> b.against) AS disagree,
count(*) FILTER(WHERE a.against <> b.against) AS disagree,
b.user_vote_count AS b_total, a.user_vote_count AS a_total
FROM user_votes a
JOIN user_votes b ON a.item_id = b.item_id
@ -149,14 +211,9 @@ async function getGraph (models) {
confidence(before - disagree, b_total - after, ${Z_CONFIDENCE})
ELSE 0 END AS trust
FROM user_pair
WHERE NOT (b_id = ANY (${SN_ADMIN_IDS}))
UNION ALL
SELECT a_id AS id, seed_id AS oid, ${MAX_TRUST}::numeric as trust
FROM user_pair, unnest(${SN_ADMIN_IDS}::int[]) seed_id
GROUP BY a_id, a_total, seed_id
UNION ALL
SELECT a_id AS id, a_id AS oid, ${MAX_TRUST}::float as trust
FROM user_pair
SELECT seed_id AS id, seed_id AS oid, 0 AS trust
FROM unnest(${seeds}::int[]) seed_id
)
SELECT id, oid, trust, sum(trust) OVER (PARTITION BY id) AS total_trust
FROM trust_pairs
@ -165,46 +222,45 @@ async function getGraph (models) {
ORDER BY id ASC`
}
async function storeTrust (models, graph, vGlobal, mPersonal) {
// convert nodeTrust into table literal string
let globalValues = ''
let personalValues = ''
vGlobal.forEach((val, [idx]) => {
if (isNaN(val)) return
if (globalValues) globalValues += ','
globalValues += `(${graph[idx].id}, ${val}::FLOAT)`
if (personalValues) personalValues += ','
personalValues += `(${GLOBAL_ROOT}, ${graph[idx].id}, ${val}::FLOAT)`
})
function reduceVectors (subName, fieldGraphVectors) {
function reduceVector (field, graph, vector, result = {}) {
vector.forEach((val, [idx]) => {
if (isNaN(val) || val <= 0) return
result[graph[idx].id] = {
...result[graph[idx].id],
subName,
userId: graph[idx].id,
[field]: val
}
})
return result
}
math.forEach(mPersonal, (val, [fromIdx, toIdx]) => {
const globalVal = vGlobal.get([toIdx, 0])
if (isNaN(val) || val - globalVal <= SIG_DIFF) return
if (personalValues) personalValues += ','
personalValues += `(${graph[fromIdx].id}, ${graph[toIdx].id}, ${val}::FLOAT)`
})
let result = {}
for (const field in fieldGraphVectors) {
result = reduceVector(field, fieldGraphVectors[field].graph, fieldGraphVectors[field].vector, result)
}
// return only the users with trust > 0
return Object.values(result).filter(s =>
Object.keys(fieldGraphVectors).reduce(
(acc, key) => acc + (s[key] ?? 0),
0
) > IRRELEVANT_CUMULATIVE_TRUST
)
}
async function storeTrust (models, subName, results) {
console.timeLog('trust', `storing trust for ${subName} with ${results.length} users`)
// update the trust of each user in graph
await models.$transaction([
models.$executeRaw`UPDATE users SET trust = 0`,
models.$executeRawUnsafe(
`UPDATE users
SET trust = g.trust
FROM (values ${globalValues}) g(id, trust)
WHERE users.id = g.id`),
models.$executeRawUnsafe(
`INSERT INTO "Arc" ("fromId", "toId", "zapTrust")
SELECT id, oid, trust
FROM (values ${personalValues}) g(id, oid, trust)
ON CONFLICT ("fromId", "toId") DO UPDATE SET "zapTrust" = EXCLUDED."zapTrust"`
),
// select all arcs that don't exist in personalValues and delete them
models.$executeRawUnsafe(
`DELETE FROM "Arc"
WHERE ("fromId", "toId") NOT IN (
SELECT id, oid
FROM (values ${personalValues}) g(id, oid, trust)
)`
)
models.userSubTrust.deleteMany({
where: {
subName
}
}),
models.userSubTrust.createMany({
data: results
})
])
}

View File

@ -32,7 +32,7 @@ export async function rankViews () {
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
try {
for (const view of ['zap_rank_personal_view']) {
for (const view of ['hot_score_view']) {
await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)
}
} finally {