Compare commits

...

8 Commits

Author SHA1 Message Date
Keyan 3310925155
increase founders fee to 70% and zap sybil fee to 30% (#1388)
* increase founders fee to 70% and zap sybil fee to 30%

* more sybil fee changes
2024-09-19 15:15:56 -05:00
k00b 020b4c5eea allow comment updates when they have boost 2024-09-19 14:06:34 -05:00
k00b e323ed27c6 fix boost hint for comments 2024-09-19 13:42:59 -05:00
k00b d17929f2c5 ss validate boost acts 2024-09-19 13:38:13 -05:00
Keyan 5f0494de30
rethinking boost (#1408)
* reuse boost for jobs

* wip

* allow job stopping

* restore upvote.js

* expire boost

* boost beyond edit window

* fix boost bolt styling

* rank comments with boost

* no random sort for jobs

* top boost for month at top of territory

* boost hints

* more boost help

* squash migrations

* for same boost, prioritize older

* show ad only if active

* fix itemCreate/Update boost expiration jobs

* fix fee button precedence
2024-09-19 13:13:14 -05:00
Keyan beba2f4794
Update awards.csv 2024-09-18 15:45:52 -05:00
ekzyis dcbe83f155
Add wss:// to user relays by default (#1412) 2024-09-18 15:42:48 -05:00
ekzyis 5088673b84
Fix margin on row overflow (#1411) 2024-09-18 11:18:08 -05:00
59 changed files with 924 additions and 446 deletions

77
api/paidAction/boost.js Normal file
View File

@ -0,0 +1,77 @@
import { msatsToSats, satsToMsats } from '@/lib/format'
export const anonable = false
export const supportsPessimism = false
export const supportsOptimism = true
export async function getCost ({ sats }) {
return satsToMsats(sats)
}
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) {
itemId = parseInt(itemId)
let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
// store a reference to the item in the invoice
await tx.invoice.update({
where: { id: invoiceId },
data: { actionId: itemId }
})
}
const act = await tx.itemAct.create({ data: { msats: cost, itemId, userId: me.id, act: 'BOOST', ...invoiceData } })
const [{ path }] = await tx.$queryRaw`
SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
return { id: itemId, sats, act: 'BOOST', path, actId: act.id }
}
export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
const [{ id, path }] = await tx.$queryRaw`
SELECT "Item".id, ltree2text(path) as path
FROM "Item"
JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId"
WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER`
return { id, sats: msatsToSats(cost), act: 'BOOST', path }
}
export async function onPaid ({ invoice, actId }, { models, 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 } })
} else if (actId) {
itemAct = await tx.itemAct.findFirst({ where: { id: actId } })
} else {
throw new Error('No invoice or actId')
}
// increment boost on item
await tx.item.update({
where: { id: itemAct.itemId },
data: {
boost: { increment: msatsToSats(itemAct.msats) }
}
})
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
VALUES ('expireBoost', jsonb_build_object('id', ${itemAct.itemId}::INTEGER), 21, true,
now() + interval '30 days', interval '40 days')`
}
export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}
export async function describe ({ id: itemId, sats }, { actionId, cost }) {
return `SN: boost ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
}

View File

@ -13,6 +13,7 @@ import * as TERRITORY_UPDATE from './territoryUpdate'
import * as TERRITORY_BILLING from './territoryBilling'
import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate'
import * as BOOST from './boost'
import wrapInvoice from 'wallets/wrap'
import { createInvoice as createUserInvoice } from 'wallets/server'
@ -21,6 +22,7 @@ export const paidActions = {
ITEM_UPDATE,
ZAP,
DOWN_ZAP,
BOOST,
POLL_VOTE,
TERRITORY_CREATE,
TERRITORY_UPDATE,
@ -186,7 +188,12 @@ export async function retryPaidAction (actionType, args, context) {
context.optimistic = true
context.me = await models.user.findUnique({ where: { id: me.id } })
const { msatsRequested, actionId } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
if (!failedInvoice) {
throw new Error(`retryPaidAction - invoice not found or not in failed state ${actionType}`)
}
const { msatsRequested, actionId } = failedInvoice
context.cost = BigInt(msatsRequested)
context.actionId = actionId
const invoiceArgs = await createSNInvoice(actionType, args, context)
@ -245,8 +252,8 @@ export async function createLightningInvoice (actionType, args, context) {
try {
const description = await paidActions[actionType].describe(args, context)
const { invoice: bolt11, wallet } = await createUserInvoice(userId, {
// this is the amount the stacker will receive, the other 1/10th is the fee
msats: cost * BigInt(9) / BigInt(10),
// this is the amount the stacker will receive, the other 3/10ths is the sybil fee
msats: cost * BigInt(7) / BigInt(10),
description,
expiry: INVOICE_EXPIRE_SECS
}, { models })

View File

@ -195,6 +195,13 @@ export async function onPaid ({ invoice, id }, context) {
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', ${item.id}::INTEGER), 21, true, now() + interval '5 seconds')`
if (item.boost > 0) {
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
VALUES ('expireBoost', jsonb_build_object('id', ${item.id}::INTEGER), 21, true,
now() + interval '30 days', interval '40 days')`
}
if (item.parentId) {
// denormalize ncomments, lastCommentAt, and "weightedComments" for ancestors, and insert into reply table
await tx.$executeRaw`

View File

@ -13,7 +13,7 @@ export async function getCost ({ id, boost = 0, uploadIds }, { me, models }) {
// or more boost
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
return BigInt(totalFeesMsats) + satsToMsats(boost - (old.boost || 0))
return BigInt(totalFeesMsats) + satsToMsats(boost - old.boost)
}
export async function perform (args, context) {
@ -30,9 +30,10 @@ export async function perform (args, context) {
}
})
const boostMsats = satsToMsats(boost - (old.boost || 0))
const newBoost = boost - old.boost
const itemActs = []
if (boostMsats > 0) {
if (newBoost > 0) {
const boostMsats = satsToMsats(newBoost)
itemActs.push({
msats: boostMsats, act: 'BOOST', userId: me?.id || USER_ID.anon
})
@ -54,15 +55,19 @@ export async function perform (args, context) {
data: { paid: true }
})
// we put boost in the where clause because we don't want to update the boost
// if it has changed concurrently
const item = await tx.item.update({
where: { id: parseInt(id) },
where: { id: parseInt(id), boost: old.boost },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } }
},
data: {
...data,
boost,
boost: {
increment: newBoost
},
pollOptions: {
createMany: {
data: pollOptions?.map(option => ({ option }))
@ -126,8 +131,17 @@ export async function perform (args, context) {
}
})
await tx.$executeRaw`INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true, now() + interval '5 seconds')`
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true,
now() + interval '5 seconds', interval '1 day')`
if (newBoost > 0) {
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
VALUES ('expireBoost', jsonb_build_object('id', ${id}::INTEGER), 21, true,
now() + interval '30 days', interval '40 days')`
}
await performBotBehavior(args, context)

View File

@ -28,7 +28,7 @@ export async function invoiceablePeer ({ id }, { models }) {
}
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) {
const feeMsats = cost / BigInt(10) // 10% fee
const feeMsats = 3n * (cost / BigInt(10)) // 30% fee
const zapMsats = cost - feeMsats
itemId = parseInt(itemId)

View File

@ -6,8 +6,9 @@ import domino from 'domino'
import {
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
USER_ID, POLL_COST,
ADMIN_ITEMS, GLOBAL_SEED, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS
USER_ID, POLL_COST, ADMIN_ITEMS, GLOBAL_SEED,
NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS,
BOOST_MULT
} from '@/lib/constants'
import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts'
@ -30,13 +31,13 @@ function commentsOrderByClause (me, models, sort) {
if (me && sort === 'hot') {
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, COALESCE(
personal_hot_score,
${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST,
${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`
} else {
if (sort === 'top') {
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator(models, 0)} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0 })} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
} else {
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator(models, 0)}/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 ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
}
}
}
@ -73,6 +74,29 @@ export async function getItem (parent, { id }, { me, models }) {
return item
}
export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { me, models }) {
return (await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${whereClause(
'"parentId" IS NULL',
'"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL',
'"Item".bio = false',
'"Item".boost > 0',
activeOrMine(),
subClause(sub, 1, 'Item', me, showNsfw),
muteClause(me))}
ORDER BY boost desc, "Item".created_at ASC
LIMIT 1`
}, ...subArr))?.[0] || null
}
const orderByClause = (by, me, models, type) => {
switch (by) {
case 'comments':
@ -88,12 +112,12 @@ const orderByClause = (by, me, models, type) => {
}
}
export function orderByNumerator (models, commentScaler = 0.5) {
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})`
END + "Item"."weightedComments"*${commentScaler}) + ${considerBoost ? `("Item".boost / ${BOOST_MULT})` : 0}`
}
export function joinZapRankPersonalView (me, models) {
@ -304,7 +328,7 @@ export default {
},
items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit = LIMIT }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
let items, user, pins, subFull, table
let items, user, pins, subFull, table, ad
// special authorization for bookmarks depending on owning users' privacy settings
if (type === 'bookmarks' && name && me?.name !== name) {
@ -442,27 +466,54 @@ export default {
models,
query: `
${SELECT},
CASE WHEN status = 'ACTIVE' AND "maxBid" > 0
THEN 0 ELSE 1 END AS group_rank,
CASE WHEN status = 'ACTIVE' AND "maxBid" > 0
THEN rank() OVER (ORDER BY "maxBid" DESC, created_at ASC)
(boost IS NOT NULL AND boost > 0)::INT AS group_rank,
CASE WHEN boost IS NOT NULL AND boost > 0
THEN rank() OVER (ORDER BY boost DESC, created_at ASC)
ELSE rank() OVER (ORDER BY created_at DESC) END AS rank
FROM "Item"
${whereClause(
'"parentId" IS NULL',
'"Item"."deletedAt" IS NULL',
'"Item"."status" = \'ACTIVE\'',
'created_at <= $1',
'"pinId" IS NULL',
subClause(sub, 4),
"status IN ('ACTIVE', 'NOSATS')"
subClause(sub, 4)
)}
ORDER BY group_rank, rank
OFFSET $2
LIMIT $3`,
orderBy: 'ORDER BY group_rank, rank'
orderBy: 'ORDER BY group_rank DESC, rank'
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
break
default:
if (decodedCursor.offset === 0) {
// get pins for the page and return those separately
pins = await itemQueryWithMeta({
me,
models,
query: `
SELECT rank_filter.*
FROM (
${SELECT}, position,
rank() OVER (
PARTITION BY "pinId"
ORDER BY "Item".created_at DESC
)
FROM "Item"
JOIN "Pin" ON "Item"."pinId" = "Pin".id
${whereClause(
'"pinId" IS NOT NULL',
'"parentId" IS NULL',
sub ? '"subName" = $1' : '"subName" IS NULL',
muteClause(me))}
) rank_filter WHERE RANK = 1
ORDER BY position ASC`,
orderBy: 'ORDER BY position ASC'
}, ...subArr)
ad = await getAd(parent, { sub, subArr, showNsfw }, { me, models })
}
items = await itemQueryWithMeta({
me,
models,
@ -478,6 +529,7 @@ export default {
'"Item"."parentId" IS NULL',
'"Item".outlawed = false',
'"Item".bio = false',
ad ? `"Item".id <> ${ad.id}` : '',
activeOrMine(me),
subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me))}
@ -487,8 +539,8 @@ export default {
orderBy: 'ORDER BY rank DESC'
}, decodedCursor.offset, limit, ...subArr)
// XXX this is just for subs that are really empty
if (decodedCursor.offset === 0 && items.length < limit) {
// XXX this is mostly for subs that are really empty
if (items.length < limit) {
items = await itemQueryWithMeta({
me,
models,
@ -504,40 +556,17 @@ export default {
'"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, 0)}/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
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, 0)}/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`
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)
}
if (decodedCursor.offset === 0) {
// get pins for the page and return those separately
pins = await itemQueryWithMeta({
me,
models,
query: `
SELECT rank_filter.*
FROM (
${SELECT}, position,
rank() OVER (
PARTITION BY "pinId"
ORDER BY "Item".created_at DESC
)
FROM "Item"
JOIN "Pin" ON "Item"."pinId" = "Pin".id
${whereClause(
'"pinId" IS NOT NULL',
'"parentId" IS NULL',
sub ? '"subName" = $1' : '"subName" IS NULL',
muteClause(me))}
) rank_filter WHERE RANK = 1
ORDER BY position ASC`,
orderBy: 'ORDER BY position ASC'
}, ...subArr)
}
break
}
break
@ -545,7 +574,8 @@ export default {
return {
cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
items,
pins
pins,
ad
}
},
item: getItem,
@ -615,18 +645,17 @@ export default {
LIMIT 3`
}, similar)
},
auctionPosition: async (parent, { id, sub, bid }, { models, me }) => {
auctionPosition: async (parent, { id, sub, boost }, { models, me }) => {
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
let where
if (bid > 0) {
// if there's a bid
// it's ACTIVE and has a larger bid than ours, or has an equal bid and is older
// count items: (bid > ours.bid OR (bid = ours.bid AND create_at < ours.created_at)) AND status = 'ACTIVE'
if (boost > 0) {
// if there's boost
// has a larger boost than ours, or has an equal boost and is older
// count items: (boost > ours.boost OR (boost = ours.boost AND create_at < ours.created_at))
where = {
status: 'ACTIVE',
OR: [
{ maxBid: { gt: bid } },
{ maxBid: bid, createdAt: { lt: createdAt } }
{ boost: { gt: boost } },
{ boost, createdAt: { lt: createdAt } }
]
}
} else {
@ -635,18 +664,42 @@ export default {
// count items: ((bid > ours.bid AND status = 'ACTIVE') OR (created_at > ours.created_at AND status <> 'STOPPED'))
where = {
OR: [
{ maxBid: { gt: 0 }, status: 'ACTIVE' },
{ createdAt: { gt: createdAt }, status: { not: 'STOPPED' } }
{ boost: { gt: 0 } },
{ createdAt: { gt: createdAt } }
]
}
}
where.subName = sub
where.AND = {
subName: sub,
status: 'ACTIVE',
deletedAt: null
}
if (id) {
where.AND.id = { not: Number(id) }
}
return await models.item.count({ where }) + 1
},
boostPosition: async (parent, { id, sub, boost }, { models, me }) => {
if (boost <= 0) {
throw new GqlInputError('boost must be greater than 0')
}
const where = {
boost: { gte: boost },
status: 'ACTIVE',
deletedAt: null,
outlawed: false
}
if (id) {
where.id = { not: Number(id) }
}
return await models.item.count({ where }) + 1
return {
home: await models.item.count({ where }) === 0,
sub: await models.item.count({ where: { ...where, subName: sub } }) === 0
}
}
},
@ -825,7 +878,6 @@ export default {
item.uploadId = item.logo
delete item.logo
}
item.maxBid ??= 0
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd })
@ -862,7 +914,7 @@ export default {
return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd })
},
act: async (parent, { id, sats, act = 'TIP', idempotent }, { me, models, lnd, headers }) => {
act: async (parent, { id, sats, act = 'TIP' }, { me, models, lnd, headers }) => {
assertApiKeyNotPermitted({ me })
await ssValidate(actSchema, { sats, act })
await assertGofacYourself({ models, headers })
@ -881,7 +933,7 @@ export default {
}
// disallow self tips except anons
if (me) {
if (me && ['TIP', 'DONT_LIKE_THIS'].includes(act)) {
if (Number(item.userId) === Number(me.id)) {
throw new GqlInputError('cannot zap yourself')
}
@ -899,6 +951,8 @@ export default {
return await performPaidAction('ZAP', { id, sats }, { me, models, lnd })
} else if (act === 'DONT_LIKE_THIS') {
return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd })
} else if (act === 'BOOST') {
return await performPaidAction('BOOST', { id, sats }, { me, models, lnd })
} else {
throw new GqlInputError('unknown act')
}
@ -1321,7 +1375,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
item = { id: Number(item.id), text: item.text, title: `@${user.name}'s bio` }
} else if (old.parentId) {
// prevent editing a comment like a post
item = { id: Number(item.id), text: item.text }
item = { id: Number(item.id), text: item.text, boost: item.boost }
} else {
item = { subName, ...item }
item.forwardUsers = await getForwardUsers(models, forward)
@ -1390,5 +1444,5 @@ export const SELECT =
ltree2text("Item"."path") AS "path"`
function topOrderByWeightedSats (me, models) {
return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC`
return `ORDER BY ${orderByNumerator({ models })} DESC NULLS LAST, "Item".id DESC`
}

View File

@ -179,17 +179,6 @@ export default {
)`
)
queries.push(
`(SELECT "Item".id::text, "Item"."statusUpdatedAt" AS "sortTime", NULL as "earnedSats",
'JobChanged' AS type
FROM "Item"
WHERE "Item"."userId" = $1
AND "maxBid" IS NOT NULL
AND "statusUpdatedAt" < $2 AND "statusUpdatedAt" <> created_at
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
// territory transfers
queries.push(
`(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL as "earnedSats",
@ -354,7 +343,8 @@ export default {
"Invoice"."actionType" = 'ITEM_CREATE' OR
"Invoice"."actionType" = 'ZAP' OR
"Invoice"."actionType" = 'DOWN_ZAP' OR
"Invoice"."actionType" = 'POLL_VOTE'
"Invoice"."actionType" = 'POLL_VOTE' OR
"Invoice"."actionType" = 'BOOST'
)
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`

View File

@ -8,6 +8,7 @@ function paidActionType (actionType) {
return 'ItemPaidAction'
case 'ZAP':
case 'DOWN_ZAP':
case 'BOOST':
return 'ItemActPaidAction'
case 'TERRITORY_CREATE':
case 'TERRITORY_UPDATE':

View File

@ -1,5 +1,5 @@
import { amountSchema, ssValidate } from '@/lib/validate'
import { getItem } from './item'
import { getAd, getItem } from './item'
import { topUsers } from './user'
import performPaidAction from '../paidAction'
import { GqlInputError } from '@/lib/error'
@ -164,6 +164,9 @@ export default {
return 0
}
return parent.total
},
ad: async (parent, args, { me, models }) => {
return await getAd(parent, { }, { me, models })
}
},
Mutation: {

View File

@ -396,22 +396,6 @@ export default {
}
}
const job = await models.item.findFirst({
where: {
maxBid: {
not: null
},
userId: me.id,
statusUpdatedAt: {
gt: lastChecked
}
}
})
if (job && job.statusUpdatedAt > job.createdAt) {
foundNotes()
return true
}
if (user.noteEarning) {
const earn = await models.earn.findFirst({
where: {

View File

@ -516,6 +516,7 @@ const resolvers = {
case 'ZAP':
case 'DOWN_ZAP':
case 'POLL_VOTE':
case 'BOOST':
return (await itemQueryWithMeta({
me,
models,
@ -532,12 +533,14 @@ const resolvers = {
const action2act = {
ZAP: 'TIP',
DOWN_ZAP: 'DONT_LIKE_THIS',
POLL_VOTE: 'POLL'
POLL_VOTE: 'POLL',
BOOST: 'BOOST'
}
switch (invoice.actionType) {
case 'ZAP':
case 'DOWN_ZAP':
case 'POLL_VOTE':
case 'BOOST':
return (await models.$queryRaw`
SELECT id, act, "invoiceId", "invoiceActionState", msats
FROM "ItemAct"

View File

@ -8,10 +8,16 @@ export default gql`
dupes(url: String!): [Item!]
related(cursor: String, title: String, id: ID, minMatch: String, limit: Limit): Items
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String, from: String, to: String): Items
auctionPosition(sub: String, id: ID, bid: Int!): Int!
auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int!
}
type BoostPositions {
home: Boolean!
sub: Boolean!
}
type TitleUnshorted {
title: String
unshorted: String
@ -46,12 +52,12 @@ export default gql`
hash: String, hmac: String): ItemPaidAction!
upsertJob(
id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): ItemPaidAction!
text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction!
upsertPoll(
id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date,
hash: String, hmac: String): ItemPaidAction!
updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id: ID, text: String!, parentId: ID, hash: String, hmac: String): ItemPaidAction!
upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction!
act(id: ID!, sats: Int, act: String, idempotent: Boolean): ItemActPaidAction!
pollVote(id: ID!): PollVotePaidAction!
toggleOutlaw(id: ID!): Item!
@ -79,6 +85,7 @@ export default gql`
cursor: String
items: [Item!]!
pins: [Item!]
ad: Item
}
type Comments {
@ -136,7 +143,6 @@ export default gql`
path: String
position: Int
prior: Int
maxBid: Int
isJob: Boolean!
pollCost: Int
poll: Poll

View File

@ -19,6 +19,7 @@ export default gql`
time: Date!
sources: [NameValue!]!
leaderboard: UsersNullable
ad: Item
}
type Reward {

View File

@ -127,3 +127,4 @@ brugeman,issue,#1311,#864,medium,high,,,50k,brugeman@stacker.news,2024-08-27
riccardobl,pr,#1342,#1141,hard,high,,pending unrelated rearchitecture,1m,rblb@getalby.com,2024-09-09
SatsAllDay,issue,#1368,#1331,medium,,,,25k,weareallsatoshi@getalby.com,2024-09-16
benalleng,helpfulness,#1368,#1170,medium,,,did a lot of it in #1175,25k,BenAllenG@stacker.news,2024-09-16
humble-GOAT,issue,#1412,#1407,good-first-issue,,,,2k,humble_GOAT@stacker.news,2024-09-18

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
127 riccardobl pr #1342 #1141 hard high pending unrelated rearchitecture 1m rblb@getalby.com 2024-09-09
128 SatsAllDay issue #1368 #1331 medium 25k weareallsatoshi@getalby.com 2024-09-16
129 benalleng helpfulness #1368 #1170 medium did a lot of it in #1175 25k BenAllenG@stacker.news 2024-09-16
130 humble-GOAT issue #1412 #1407 good-first-issue 2k humble_GOAT@stacker.news 2024-09-18

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import AccordianItem from './accordian-item'
import { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
import InputGroup from 'react-bootstrap/InputGroup'
@ -11,6 +11,8 @@ import { useMe } from './me'
import { useFeeButton } from './fee-button'
import { useRouter } from 'next/router'
import { useFormikContext } from 'formik'
import { gql, useLazyQuery } from '@apollo/client'
import useDebounceCallback from './use-debounce-callback'
const EMPTY_FORWARD = { nym: '', pct: '' }
@ -26,9 +28,118 @@ const FormStatus = {
ERROR: 'error'
}
export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
export function BoostHelp () {
return (
<ol style={{ lineHeight: 1.25 }}>
<li>Boost ranks items higher based on the amount</li>
<li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li>
<li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker
<ul>
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li>
</ul>
</li>
<li>The decay of boost "votes" increases at 1.25x the rate of organic votes
<ul>
<li>i.e. boost votes fall out of ranking faster</li>
</ul>
</li>
<li>boost can take a few minutes to show higher ranking in feed</li>
<li>100% of boost goes to the territory founder and top stackers as rewards</li>
</ol>
)
}
export function BoostInput ({ onChange, ...props }) {
const feeButton = useFeeButton()
let merge
if (feeButton) {
({ merge } = feeButton)
}
return (
<Input
label={
<div className='d-flex align-items-center'>boost
<Info>
<BoostHelp />
</Info>
</div>
}
name='boost'
onChange={(_, e) => {
merge?.({
boost: {
term: `+ ${e.target.value}`,
label: 'boost',
op: '+',
modifier: cost => cost + Number(e.target.value)
}
})
onChange && onChange(_, e)
}}
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
{...props}
/>
)
}
// act means we are adding to existing boost
export function BoostItemInput ({ item, sub, act = false, ...props }) {
const [boost, setBoost] = useState(Number(item?.boost) + (act ? BOOST_MULT : 0))
const [getBoostPosition, { data }] = useLazyQuery(gql`
query BoostPosition($id: ID, $boost: Int) {
boostPosition(sub: "${item?.subName || sub?.name}", id: $id, boost: $boost) {
home
sub
}
}`,
{ fetchPolicy: 'cache-and-network' })
const getPositionDebounce = useDebounceCallback((...args) => getBoostPosition(...args), 1000, [getBoostPosition])
useEffect(() => {
if (boost) {
getPositionDebounce({ variables: { boost: Number(boost), id: item?.id } })
}
}, [boost, item?.id])
const boostMessage = useMemo(() => {
if (!item?.parentId) {
if (data?.boostPosition?.home || data?.boostPosition?.sub) {
const boostPinning = []
if (data?.boostPosition?.home) {
boostPinning.push('homepage')
}
if (data?.boostPosition?.sub) {
boostPinning.push(`~${item?.subName || sub?.name}`)
}
return `pins to the top of ${boostPinning.join(' and ')}`
}
}
if (boost >= 0 && boost % BOOST_MULT === 0) {
return `${act ? 'brings to' : 'equivalent to'} ${numWithUnits(boost / BOOST_MULT, { unitPlural: 'zapvotes', unitSingular: 'zapvote' })}`
}
return 'ranks posts higher based on the amount'
}, [boost, data?.boostPosition?.home, data?.boostPosition?.sub, item?.subName, sub?.name])
return (
<BoostInput
hint={<span className='text-muted'>{boostMessage}</span>}
onChange={(_, e) => {
if (e.target.value >= 0) {
setBoost(Number(e.target.value) + (act ? Number(item?.boost) : 0))
}
}}
{...props}
/>
)
}
export default function AdvPostForm ({ children, item, sub, storageKeyPrefix }) {
const { me } = useMe()
const { merge } = useFeeButton()
const router = useRouter()
const [itemType, setItemType] = useState()
const formik = useFormikContext()
@ -111,39 +222,7 @@ export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
body={
<>
{children}
<Input
label={
<div className='d-flex align-items-center'>boost
<Info>
<ol>
<li>Boost ranks posts higher temporarily based on the amount</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to one trusted upvote
<ul>
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like 5 votes</li>
</ul>
</li>
<li>The decay of boost "votes" increases at 1.25x the rate of organic votes
<ul>
<li>i.e. boost votes fall out of ranking faster</li>
</ul>
</li>
<li>100% of sats from boost are given back to top stackers as rewards</li>
</ol>
</Info>
</div>
}
name='boost'
onChange={(_, e) => merge({
boost: {
term: `+ ${e.target.value}`,
label: 'boost',
modifier: cost => cost + Number(e.target.value)
}
})}
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<BoostItemInput item={item} sub={sub} />
<VariableInput
label='forward sats to'
name='forward'

View File

@ -0,0 +1,63 @@
import { useShowModal } from './modal'
import { useToast } from './toast'
import ItemAct from './item-act'
import AccordianItem from './accordian-item'
import { useMemo } from 'react'
import getColor from '@/lib/rainbow'
import UpBolt from '@/svgs/bolt.svg'
import styles from './upvote.module.css'
import { BoostHelp } from './adv-post-form'
import { BOOST_MULT } from '@/lib/constants'
import classNames from 'classnames'
export default function Boost ({ item, className, ...props }) {
const { boost } = item
const style = useMemo(() => (boost
? {
fill: getColor(boost),
filter: `drop-shadow(0 0 6px ${getColor(boost)}90)`,
transform: 'scaleX(-1)'
}
: {
transform: 'scaleX(-1)'
}), [boost])
return (
<Booster
item={item} As={({ ...oprops }) =>
<div className='upvoteParent'>
<div
className={styles.upvoteWrapper}
>
<UpBolt
{...props} {...oprops} style={style}
width={26}
height={26}
className={classNames(styles.upvote, className, boost && styles.voted)}
/>
</div>
</div>}
/>
)
}
function Booster ({ item, As, children }) {
const toaster = useToast()
const showModal = useShowModal()
return (
<As
onClick={async () => {
try {
showModal(onClose =>
<ItemAct onClose={onClose} item={item} act='BOOST' step={BOOST_MULT}>
<AccordianItem header='what is boost?' body={<BoostHelp />} />
</ItemAct>)
} catch (error) {
toaster.danger('failed to boost item')
}
}}
>
{children}
</As>
)
}

View File

@ -80,7 +80,7 @@ export function BountyForm ({
: null
}
/>
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} />
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub} />
<ItemButtonBar itemId={item?.id} canDelete={false} />
</Form>
)

View File

@ -25,6 +25,7 @@ import Skull from '@/svgs/death-skull.svg'
import { commentSubTreeRootId } from '@/lib/item'
import Pin from '@/svgs/pushpin-fill.svg'
import LinkToContext from './link-to-context'
import Boost from './boost-button'
function Parent ({ item, rootText }) {
const root = useRoot()
@ -144,9 +145,11 @@ export default function Comment ({
<div className={`${itemStyles.item} ${styles.item}`}>
{item.outlawed && !me?.privates?.wildWestMode
? <Skull className={styles.dontLike} width={24} height={24} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
: item.mine
? <Boost item={item} className={styles.upvote} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'>
{item.user?.meMute && !includeParent && collapse === 'yep'

View File

@ -79,7 +79,7 @@ export function DiscussionForm ({
? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div>
: null}
/>
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} />
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub} />
<ItemButtonBar itemId={item?.id} />
{!item &&
<div className={`mt-3 ${related.length > 0 ? '' : 'invisible'}`}>

View File

@ -17,7 +17,12 @@ export function DownZap ({ item, ...props }) {
}
: undefined), [meDontLikeSats])
return (
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
<DownZapper
item={item} As={({ ...oprops }) =>
<div className='upvoteParent'>
<Flag {...props} {...oprops} style={style} />
</div>}
/>
)
}
@ -31,7 +36,7 @@ function DownZapper ({ item, As, children }) {
try {
showModal(onClose =>
<ItemAct
onClose={onClose} item={item} down
onClose={onClose} item={item} act='DONT_LIKE_THIS'
>
<AccordianItem
header='what is a downzap?' body={

View File

@ -21,6 +21,7 @@ export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me })
anonCharge: {
term: `x ${ANON_FEE_MULTIPLIER}`,
label: 'anon mult',
op: '*',
modifier: (cost) => cost * ANON_FEE_MULTIPLIER
}
}
@ -28,6 +29,7 @@ export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me })
baseCost: {
term: baseCost,
label: `${comment ? 'comment' : 'post'} cost`,
op: '_',
modifier: (cost) => cost + baseCost,
allowFreebies: comment
},
@ -48,10 +50,12 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
useEffect(() => {
const repetition = data?.itemRepetition
if (!repetition) return setLine({})
console.log('repetition', repetition)
setLine({
itemRepetition: {
term: <>x 10<sup>{repetition}</sup></>,
label: <>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</>,
op: '*',
modifier: (cost) => cost * Math.pow(10, repetition)
}
})
@ -61,6 +65,35 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
}
}
function sortHelper (a, b) {
if (a.op === '_') {
return -1
} else if (b.op === '_') {
return 1
} else if (a.op === '*' || a.op === '/') {
if (b.op === '*' || b.op === '/') {
return 0
}
// a is higher precedence
return -1
} else {
if (b.op === '*' || b.op === '/') {
// b is higher precedence
return 1
}
// postive first
if (a.op === '+' && b.op === '-') {
return -1
}
if (a.op === '-' && b.op === '+') {
return 1
}
// both are + or -
return 0
}
}
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
const [lineItems, setLineItems] = useState({})
const [disabled, setDisabled] = useState(false)
@ -77,7 +110,7 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = ()
const value = useMemo(() => {
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
const total = Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0)
const total = Object.values(lines).sort(sortHelper).reduce((acc, { modifier }) => modifier(acc), 0)
// freebies: there's only a base cost and we don't have enough sats
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total && !me?.privates?.disableFreebies
return {
@ -145,7 +178,7 @@ function Receipt ({ lines, total }) {
return (
<Table className={styles.receipt} borderless size='sm'>
<tbody>
{Object.entries(lines).map(([key, { term, label, omit }]) => (
{Object.entries(lines).sort(([, a], [, b]) => sortHelper(a, b)).map(([key, { term, label, omit }]) => (
!omit &&
<tr key={key}>
<td>{term}</td>

View File

@ -42,7 +42,7 @@ export class SessionRequiredError extends Error {
}
export function SubmitButton ({
children, variant, value, onClick, disabled, appendText, submittingText,
children, variant, valueName = 'submit', value, onClick, disabled, appendText, submittingText,
className, ...props
}) {
const formik = useFormikContext()
@ -58,7 +58,7 @@ export function SubmitButton ({
disabled={disabled}
onClick={value
? e => {
formik.setFieldValue('submit', value)
formik.setFieldValue(valueName, value)
onClick && onClick(e)
}
: onClick}
@ -141,6 +141,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
uploadFees: {
term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`,
label: 'upload fee',
op: '+',
modifier: cost => cost + uploadFees.totalFees,
omit: !uploadFees.totalFees
}

View File

@ -4,7 +4,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'
import { Form, Input, SubmitButton } from './form'
import { useMe } from './me'
import UpBolt from '@/svgs/bolt.svg'
import { amountSchema } from '@/lib/validate'
import { amountSchema, boostSchema } from '@/lib/validate'
import { useToast } from './toast'
import { useLightning } from './lightning'
import { nextTip, defaultTipIncludingRandom } from './upvote'
@ -12,6 +12,7 @@ import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { usePaidMutation } from './use-paid-mutation'
import { ACT_MUTATION } from '@/fragments/paidAction'
import { meAnonSats } from '@/lib/apollo'
import { BoostItemInput } from './adv-post-form'
const defaultTips = [100, 1000, 10_000, 100_000]
@ -53,7 +54,39 @@ const setItemMeAnonSats = ({ id, amount }) => {
window.localStorage.setItem(storageKey, existingAmount + amount)
}
export default function ItemAct ({ onClose, item, down, children, abortSignal }) {
function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'BOOST' }) {
return (
<Form
initial={{
amount: step
}}
schema={boostSchema}
onSubmit={onSubmit}
>
<BoostItemInput
label='add boost'
act
name='amount'
type='number'
innerRef={inputRef}
overrideValue={oValue}
sub={item.sub}
step={step}
required
autoFocus
item={item}
/>
{children}
<div className='d-flex mt-3'>
<SubmitButton variant='success' className='ms-auto mt-1 px-4' value={act}>
boost
</SubmitButton>
</div>
</Form>
)
}
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
const inputRef = useRef(null)
const { me } = useMe()
const [oValue, setOValue] = useState()
@ -62,7 +95,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
inputRef.current?.focus()
}, [onClose, item.id])
const act = useAct()
const actor = useAct()
const strike = useLightning()
const onSubmit = useCallback(async ({ amount }) => {
@ -76,18 +109,18 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
}
}
}
const { error } = await act({
const { error } = await actor({
variables: {
id: item.id,
sats: Number(amount),
act: down ? 'DONT_LIKE_THIS' : 'TIP'
act
},
optimisticResponse: me
? {
act: {
__typename: 'ItemActPaidAction',
result: {
id: item.id, sats: Number(amount), act: down ? 'DONT_LIKE_THIS' : 'TIP', path: item.path
id: item.id, sats: Number(amount), act, path: item.path
}
}
}
@ -101,36 +134,40 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
})
if (error) throw error
addCustomTip(Number(amount))
}, [me, act, down, item.id, onClose, abortSignal, strike])
}, [me, actor, act, item.id, onClose, abortSignal, strike])
return (
<Form
initial={{
amount: defaultTipIncludingRandom(me?.privates) || defaultTips[0],
default: false
}}
schema={amountSchema}
onSubmit={onSubmit}
>
<Input
label='amount'
name='amount'
type='number'
innerRef={inputRef}
overrideValue={oValue}
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div>
<Tips setOValue={setOValue} />
</div>
{children}
<div className='d-flex mt-3'>
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
</div>
</Form>
)
return act === 'BOOST'
? <BoostForm step={step} onSubmit={onSubmit} item={item} oValue={oValue} inputRef={inputRef} act={act}>{children}</BoostForm>
: (
<Form
initial={{
amount: defaultTipIncludingRandom(me?.privates) || defaultTips[0]
}}
schema={amountSchema}
onSubmit={onSubmit}
>
<Input
label='amount'
name='amount'
type='number'
innerRef={inputRef}
overrideValue={oValue}
step={step}
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div>
<Tips setOValue={setOValue} />
</div>
{children}
<div className='d-flex mt-3'>
<SubmitButton variant={act === 'DONT_LIKE_THIS' ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value={act}>
{act === 'DONT_LIKE_THIS' ? 'downzap' : 'zap'}
</SubmitButton>
</div>
</Form>)
}
function modifyActCache (cache, { result, invoice }) {
@ -156,6 +193,12 @@ function modifyActCache (cache, { result, invoice }) {
return existingSats + sats
}
return existingSats
},
boost: (existingBoost = 0) => {
if (act === 'BOOST') {
return existingBoost + sats
}
return existingBoost
}
}
})

View File

@ -1,6 +1,5 @@
import { string } from 'yup'
import Toc from './table-of-contents'
import Badge from 'react-bootstrap/Badge'
import Button from 'react-bootstrap/Button'
import Image from 'react-bootstrap/Image'
import { SearchTitle } from './item'
@ -11,6 +10,8 @@ import EmailIcon from '@/svgs/mail-open-line.svg'
import Share from './share'
import Hat from './hat'
import { MEDIA_URL } from '@/lib/constants'
import { abbrNum } from '@/lib/format'
import { Badge } from 'react-bootstrap'
export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = string().email().isValidSync(item.url)
@ -50,6 +51,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
</>}
<wbr />
<span> \ </span>
{item.boost > 0 && <span>{abbrNum(item.boost)} boost \ </span>}
<span>
<Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'>
@{item.user.name}<Hat className='ms-1 fill-grey' user={item.user} height={12} width={12} />
@ -59,17 +61,21 @@ export default function ItemJob ({ item, toc, rank, children }) {
{timeSince(new Date(item.createdAt))}
</Link>
</span>
{item.mine &&
{item.subName &&
<Link href={`/~${item.subName}`}>
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
</Link>}
{item.status === 'STOPPED' &&
<>{' '}<Badge bg='info' className={styles.badge}>stopped</Badge></>}
{item.mine && !item.deletedAt &&
(
<>
<wbr />
<span> \ </span>
<Link href={`/items/${item.id}/edit`} className='text-reset'>
<Link href={`/items/${item.id}/edit`} className='text-reset fw-bold'>
edit
</Link>
{item.status !== 'ACTIVE' && <span className='ms-1 fw-bold text-boost'> {item.status}</span>}
</>)}
{item.maxBid > 0 && item.status === 'ACTIVE' && <Badge className={`${styles.newComment} ms-1`}>PROMOTED</Badge>}
</div>
</div>
{toc &&

View File

@ -24,6 +24,7 @@ import removeMd from 'remove-markdown'
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, parseInternalLinks } from '@/lib/url'
import ItemPopover from './item-popover'
import { useMe } from './me'
import Boost from './boost-button'
function onItemClick (e, router, item) {
const viewedAt = commentsViewedAt(item)
@ -105,11 +106,13 @@ export default function Item ({
<div className={classNames(styles.item, itemClassName)}>
{item.position && (pinnable || !item.subName)
? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: Number(item.user?.id) === USER_ID.ad
? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} />}
: item.mine
? <Boost item={item} className={styles.upvote} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: Number(item.user?.id) === USER_ID.ad
? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} />}
<div className={styles.hunk}>
<div className={`${styles.main} flex-wrap`}>
<Link

View File

@ -46,6 +46,12 @@ a.title:visited {
margin-bottom: .15rem;
}
.badge {
vertical-align: middle;
margin-top: -1px;
margin-left: 0.1rem;
}
.newComment {
color: var(--theme-grey) !important;
background: var(--theme-clickToContextColor) !important;

View File

@ -15,7 +15,7 @@ export default function Items ({ ssrData, variables = {}, query, destructureData
const Foooter = Footer || MoreFooter
const dat = useData(data, ssrData)
const { items, pins, cursor } = useMemo(() => {
const { items, pins, ad, cursor } = useMemo(() => {
if (!dat) return {}
if (destructureData) {
return destructureData(dat)
@ -50,6 +50,7 @@ export default function Items ({ ssrData, variables = {}, query, destructureData
return (
<>
<div className={styles.grid}>
{ad && <ListItem item={ad} ad />}
{itemsWithPins.filter(filter).map((item, i) => (
<ListItem key={`${item.id}-${i + 1}`} item={item} rank={rank && i + 1} itemClassName={variables.includeComments ? 'py-2' : ''} pinnable={isHome ? false : pins?.length > 0} />
))}

View File

@ -1,46 +1,41 @@
import { Checkbox, Form, Input, MarkdownInput } from './form'
import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form'
import Row from 'react-bootstrap/Row'
import Col from 'react-bootstrap/Col'
import InputGroup from 'react-bootstrap/InputGroup'
import Image from 'react-bootstrap/Image'
import BootstrapForm from 'react-bootstrap/Form'
import Alert from 'react-bootstrap/Alert'
import { useEffect, useState } from 'react'
import Info from './info'
import AccordianItem from './accordian-item'
import styles from '@/styles/post.module.css'
import { useLazyQuery, gql } from '@apollo/client'
import Link from 'next/link'
import { usePrice } from './price'
import Avatar from './avatar'
import { jobSchema } from '@/lib/validate'
import { MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants'
import { ItemButtonBar } from './post'
import { useFormikContext } from 'formik'
import { BOOST_MIN, BOOST_MULT, MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants'
import { UPSERT_JOB } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'
function satsMin2Mo (minute) {
return minute * 30 * 24 * 60
}
function PriceHint ({ monthly }) {
const { price, fiatSymbol } = usePrice()
if (!price || !monthly) {
return null
}
const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
const fiat = fixed((price / 100000000) * monthly, 0)
return <span className='text-muted'>{monthly} sats/mo which is {fiatSymbol}{fiat}/mo</span>
}
import { BoostInput } from './adv-post-form'
import { numWithUnits, giveOrdinalSuffix } from '@/lib/format'
import useDebounceCallback from './use-debounce-callback'
import FeeButton from './fee-button'
import CancelButton from './cancel-button'
// need to recent list items
export default function JobForm ({ item, sub }) {
const storageKeyPrefix = item ? undefined : `${sub.name}-job`
const [logoId, setLogoId] = useState(item?.uploadId)
const [getAuctionPosition, { data }] = useLazyQuery(gql`
query AuctionPosition($id: ID, $boost: Int) {
auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, boost: $boost)
}`,
{ fetchPolicy: 'cache-and-network' })
const getPositionDebounce = useDebounceCallback((...args) => getAuctionPosition(...args), 1000, [getAuctionPosition])
useEffect(() => {
if (item?.boost) {
getPositionDebounce({ variables: { boost: item.boost, id: item.id } })
}
}, [item?.boost])
const extraValues = logoId ? { logo: Number(logoId) } : {}
const onSubmit = useItemSubmit(UPSERT_JOB, { item, sub, extraValues })
@ -53,13 +48,13 @@ export default function JobForm ({ item, sub }) {
company: item?.company || '',
location: item?.location || '',
remote: item?.remote || false,
boost: item?.boost || '',
text: item?.text || '',
url: item?.url || '',
maxBid: item?.maxBid || 0,
stop: false,
start: false
}}
schema={jobSchema}
schema={jobSchema({ existingBoost: item?.boost })}
storageKeyPrefix={storageKeyPrefix}
requireSession
onSubmit={onSubmit}
@ -115,130 +110,46 @@ export default function JobForm ({ item, sub }) {
required
clear
/>
<PromoteJob item={item} sub={sub} />
{item && <StatusControl item={item} />}
<ItemButtonBar itemId={item?.id} canDelete={false} />
<BoostInput
label={
<div className='d-flex align-items-center'>boost
<Info>
<ol className='line-height-md'>
<li>Boost ranks jobs higher based on the amount</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
<li>Boost must be divisible by {numWithUnits(BOOST_MULT, { abbreviate: false })}</li>
<li>100% of boost goes to the territory founder and top stackers as rewards</li>
</ol>
</Info>
</div>
}
hint={<span className='text-muted'>{data?.auctionPosition ? `your job will rank ${giveOrdinalSuffix(data.auctionPosition)}` : 'higher boost ranks your job higher'}</span>}
onChange={(_, e) => getPositionDebounce({ variables: { boost: Number(e.target.value), id: item?.id } })}
/>
<JobButtonBar itemId={item?.id} />
</Form>
</>
)
}
const FormStatus = {
DIRTY: 'dirty',
ERROR: 'error'
}
function PromoteJob ({ item, sub }) {
const formik = useFormikContext()
const [show, setShow] = useState(false)
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0))
const [getAuctionPosition, { data }] = useLazyQuery(gql`
query AuctionPosition($id: ID, $bid: Int!) {
auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, bid: $bid)
}`,
{ fetchPolicy: 'cache-and-network' })
const position = data?.auctionPosition
useEffect(() => {
const initialMaxBid = Number(item?.maxBid) || 0
getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
setMonthly(satsMin2Mo(initialMaxBid))
}, [])
useEffect(() => {
if (formik?.values?.maxBid !== 0) {
setShow(FormStatus.DIRTY)
}
}, [formik?.values])
useEffect(() => {
const hasMaxBidError = !!formik?.errors?.maxBid
// if it's open we don't want to collapse on submit
setShow(show => show || (hasMaxBidError && formik?.isSubmitting && FormStatus.ERROR))
}, [formik?.isSubmitting])
export function JobButtonBar ({
itemId, disable, className, children, handleStop, onCancel, hasCancel = true,
createText = 'post', editText = 'save', stopText = 'remove'
}) {
return (
<AccordianItem
show={show}
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>}
body={
<>
<Input
label={
<div className='d-flex align-items-center'>bid
<Info>
<ol>
<li>The higher your bid the higher your job will rank</li>
<li>You can increase, decrease, or remove your bid at anytime</li>
<li>You can edit or stop your job at anytime</li>
<li>If you run out of sats, your job will stop being promoted until you fill your wallet again</li>
</ol>
</Info>
<small className='text-muted ms-2'>optional</small>
</div>
}
name='maxBid'
onChange={async (formik, e) => {
if (e.target.value >= 0 && e.target.value <= 100000000) {
setMonthly(satsMin2Mo(e.target.value))
getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } })
} else {
setMonthly(satsMin2Mo(0))
}
}}
append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>}
hint={<PriceHint monthly={monthly} />}
<div className={`mt-3 ${className}`}>
<div className='d-flex justify-content-between'>
{itemId &&
<SubmitButton valueName='status' value='STOPPED' variant='grey-medium'>{stopText}</SubmitButton>}
{children}
<div className='d-flex align-items-center ms-auto'>
{hasCancel && <CancelButton onClick={onCancel} />}
<FeeButton
text={itemId ? editText : createText}
variant='secondary'
disabled={disable}
/>
<><div className='fw-bold text-muted'>This bid puts your job in position: {position}</div></>
</>
}
/>
)
}
function StatusControl ({ item }) {
let StatusComp
if (item.status !== 'STOPPED') {
StatusComp = () => {
return (
<>
<AccordianItem
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>I want to stop my job</div>}
headerColor='var(--bs-danger)'
body={
<Checkbox
label={<div className='fw-bold text-danger'>stop my job</div>} name='stop' inline
/>
}
/>
</>
)
}
} else if (item.status === 'STOPPED') {
StatusComp = () => {
return (
<AccordianItem
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>I want to resume my job</div>}
headerColor='var(--bs-success)'
body={
<Checkbox
label={<div className='fw-bold text-success'>resume my job</div>} name='start' inline
/>
}
/>
)
}
}
return (
<div className='my-3 border border-3 rounded'>
<div className='p-3'>
<BootstrapForm.Label>job control</BootstrapForm.Label>
{item.status === 'NOSATS' &&
<Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' className='text-reset text-underline'>fund your wallet</Link> or reduce bid to continue promoting your job</Alert>}
<StatusComp />
</div>
</div>
</div>
)

View File

@ -163,7 +163,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
}
}}
/>
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item}>
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub}>
<MarkdownInput
label='context'
name='text'

View File

@ -401,22 +401,24 @@ export function Sorts ({ sub, prefix, className }) {
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item className={className}>
<Link href={prefix + '/random'} passHref legacyBehavior>
<Nav.Link eventKey='random' className={styles.navLink}>random</Nav.Link>
</Link>
</Nav.Item>
{sub !== 'jobs' &&
<Nav.Item className={className}>
<Link
href={{
pathname: '/~/top/[type]/[when]',
query: { type: 'posts', when: 'day', sub }
}} as={prefix + '/top/posts/day'} passHref legacyBehavior
>
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
</Link>
</Nav.Item>}
<>
<Nav.Item className={className}>
<Link href={prefix + '/random'} passHref legacyBehavior>
<Nav.Link eventKey='random' className={styles.navLink}>random</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item className={className}>
<Link
href={{
pathname: '/~/top/[type]/[when]',
query: { type: 'posts', when: 'day', sub }
}} as={prefix + '/top/posts/day'} passHref legacyBehavior
>
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
</Link>
</Nav.Item>
</>}
</>
)
}

View File

@ -406,9 +406,18 @@ function Invoicification ({ n: { invoice, sortTime } }) {
invoiceId = invoice.item.poll?.meInvoiceId
invoiceActionState = invoice.item.poll?.meInvoiceActionState
} else {
actionString = `${invoice.actionType === 'ZAP'
? invoice.item.root?.bounty ? 'bounty payment' : 'zap'
: 'downzap'} on ${itemType} `
if (invoice.actionType === 'ZAP') {
if (invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root.mine) {
actionString = 'bounty payment'
} else {
actionString = 'zap'
}
} else if (invoice.actionType === 'DOWN_ZAP') {
actionString = 'downzap'
} else if (invoice.actionType === 'BOOST') {
actionString = 'boost'
}
actionString = `${actionString} on ${itemType} `
retry = actRetry;
({ id: invoiceId, actionState: invoiceActionState } = invoice.itemAct.invoice)
}

View File

@ -62,7 +62,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
: null}
maxLength={MAX_POLL_CHOICE_LENGTH}
/>
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item}>
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub}>
<DateTimeInput
isClearable
label='poll expiration'

View File

@ -187,7 +187,7 @@ export default function Post ({ sub }) {
export function ItemButtonBar ({
itemId, canDelete = true, disable,
className, children, onDelete, onCancel, hasCancel = true,
createText = 'post', editText = 'save'
createText = 'post', editText = 'save', deleteText = 'delete'
}) {
const router = useRouter()
@ -199,7 +199,7 @@ export function ItemButtonBar ({
itemId={itemId}
onDelete={onDelete || (() => router.push(`/items/${itemId}`))}
>
<Button variant='grey-medium'>delete</Button>
<Button variant='grey-medium'>{deleteText}</Button>
</Delete>}
{children}
<div className='d-flex align-items-center ms-auto'>

View File

@ -77,6 +77,7 @@ export default function TerritoryForm ({ sub }) {
lines.paid = {
term: `- ${abbrNum(alreadyBilled)} sats`,
label: 'already paid',
op: '-',
modifier: cost => cost - alreadyBilled
}
return lines

View File

@ -12,6 +12,7 @@ import Popover from 'react-bootstrap/Popover'
import { useShowModal } from './modal'
import { numWithUnits } from '@/lib/format'
import { Dropdown } from 'react-bootstrap'
import classNames from 'classnames'
const UpvotePopover = ({ target, show, handleClose }) => {
const { me } = useMe()
@ -226,7 +227,14 @@ export default function UpVote ({ item, className }) {
}
}
const fillColor = hover || pending ? nextColor : color
const fillColor = meSats && (hover || pending ? nextColor : color)
const style = useMemo(() => (fillColor
? {
fill: fillColor,
filter: `drop-shadow(0 0 6px ${fillColor}90)`
}
: undefined), [fillColor])
return (
<div ref={ref} className='upvoteParent'>
@ -236,7 +244,7 @@ export default function UpVote ({ item, className }) {
>
<ActionTooltip notForm disable={disabled} overlayText={overlayText}>
<div
className={`${disabled ? styles.noSelfTips : ''} ${styles.upvoteWrapper}`}
className={classNames(disabled && styles.noSelfTips, styles.upvoteWrapper)}
>
<UpBolt
onPointerEnter={() => setHover(true)}
@ -244,19 +252,12 @@ export default function UpVote ({ item, className }) {
onTouchEnd={() => setHover(false)}
width={26}
height={26}
className={
`${styles.upvote}
${className || ''}
${disabled ? styles.noSelfTips : ''}
${meSats ? styles.voted : ''}
${pending ? styles.pending : ''}`
}
style={meSats || hover || pending
? {
fill: fillColor,
filter: `drop-shadow(0 0 6px ${fillColor}90)`
}
: undefined}
className={classNames(styles.upvote,
className,
disabled && styles.noSelfTips,
meSats && styles.voted,
pending && styles.pending)}
style={style}
/>
</div>
</ActionTooltip>

View File

@ -13,8 +13,7 @@
}
.noSelfTips {
fill: transparent !important;
filter: none !important;
transform: scaleX(-1);
}
.upvoteWrapper:not(.noSelfTips):hover {

View File

@ -24,7 +24,7 @@ export default function useItemSubmit (mutation,
const { me } = useMe()
return useCallback(
async ({ boost, crosspost, title, options, bounty, maxBid, start, stop, ...values }, { resetForm }) => {
async ({ boost, crosspost, title, options, bounty, status, ...values }, { resetForm }) => {
if (options) {
// remove existing poll options since else they will be appended as duplicates
options = options.slice(item?.poll?.options?.length || 0).filter(o => o.trim().length > 0)
@ -44,10 +44,9 @@ export default function useItemSubmit (mutation,
variables: {
id: item?.id,
sub: item?.subName || sub?.name,
boost: boost ? Number(boost) : undefined,
boost: boost ? Number(boost) : item?.boost ? Number(item.boost) : undefined,
bounty: bounty ? Number(bounty) : undefined,
maxBid: (maxBid || Number(maxBid) === 0) ? Number(maxBid) : undefined,
status: start ? 'ACTIVE' : stop ? 'STOPPED' : undefined,
status: status === 'STOPPED' ? 'STOPPED' : 'ACTIVE',
title: title?.trim(),
options,
...values,

View File

@ -46,15 +46,14 @@ export const ITEM_FIELDS = gql`
ncomments
commentSats
lastCommentAt
maxBid
isJob
status
company
location
remote
subName
pollCost
pollExpiresAt
status
uploadId
mine
imgproxyUrls
@ -79,6 +78,7 @@ export const ITEM_FULL_FIELDS = gql`
bounty
bountyPaidTo
subName
mine
user {
id
name

View File

@ -133,11 +133,11 @@ export const UPSERT_DISCUSSION = gql`
export const UPSERT_JOB = gql`
${PAID_ACTION}
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!,
$location: String, $remote: Boolean, $text: String!, $url: String!, $maxBid: Int!,
$location: String, $remote: Boolean, $text: String!, $url: String!, $boost: Int,
$status: String, $logo: Int) {
upsertJob(sub: $sub, id: $id, title: $title, company: $company,
location: $location, remote: $remote, text: $text,
url: $url, maxBid: $maxBid, status: $status, logo: $logo) {
url: $url, boost: $boost, status: $status, logo: $logo) {
result {
id
deleteScheduledAt
@ -218,8 +218,8 @@ export const CREATE_COMMENT = gql`
export const UPDATE_COMMENT = gql`
${ITEM_PAID_ACTION_FIELDS}
${PAID_ACTION}
mutation upsertComment($id: ID!, $text: String!, ${HASH_HMAC_INPUT_1}) {
upsertComment(id: $id, text: $text, ${HASH_HMAC_INPUT_2}) {
mutation upsertComment($id: ID!, $text: String!, $boost: Int, ${HASH_HMAC_INPUT_1}) {
upsertComment(id: $id, text: $text, boost: $boost, ${HASH_HMAC_INPUT_2}) {
...ItemPaidActionFields
...PaidActionFields
}

View File

@ -87,6 +87,9 @@ export const SUB_ITEMS = gql`
...CommentItemExtFields @include(if: $includeComments)
position
}
ad {
...ItemFields
}
}
}
`

View File

@ -180,7 +180,8 @@ function getClient (uri) {
return {
cursor: incoming.cursor,
items: [...(existing?.items || []), ...incoming.items],
pins: [...(existing?.pins || []), ...(incoming.pins || [])]
pins: [...(existing?.pins || []), ...(incoming.pins || [])],
ad: incoming?.ad || existing?.ad
}
}
},

View File

@ -6,10 +6,10 @@ export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']
export const NOFOLLOW_LIMIT = 1000
export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener'
export const BOOST_MULT = 5000
export const BOOST_MIN = BOOST_MULT * 10
export const UPLOAD_SIZE_MAX = 50 * 1024 * 1024
export const UPLOAD_SIZE_MAX_AVATAR = 5 * 1024 * 1024
export const BOOST_MULT = 10000
export const BOOST_MIN = BOOST_MULT
export const IMAGE_PIXELS_MAX = 35000000
// backwards compatibile with old media domain env var and precedence for docker url if set
export const MEDIA_URL = process.env.MEDIA_URL_DOCKER || process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}`
@ -25,7 +25,7 @@ export const UPLOAD_TYPES_ALLOW = [
'video/webm'
]
export const AVATAR_TYPES_ALLOW = UPLOAD_TYPES_ALLOW.filter(t => t.startsWith('image/'))
export const INVOICE_ACTION_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'POLL_VOTE']
export const INVOICE_ACTION_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'POLL_VOTE', 'BOOST']
export const BOUNTY_MIN = 1000
export const BOUNTY_MAX = 10000000
export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL']
@ -91,16 +91,19 @@ export const TERRITORY_BILLING_OPTIONS = (labelPrefix) => ({
monthly: {
term: '+ 100k',
label: `${labelPrefix} month`,
op: '+',
modifier: cost => cost + TERRITORY_COST_MONTHLY
},
yearly: {
term: '+ 1m',
label: `${labelPrefix} year`,
op: '+',
modifier: cost => cost + TERRITORY_COST_YEARLY
},
once: {
term: '+ 3m',
label: 'one time',
op: '+',
modifier: cost => cost + TERRITORY_COST_ONCE
}
})

View File

@ -110,3 +110,18 @@ export const ensureB64 = hexOrB64Url => {
throw new Error('not a valid hex or base64 url or base64 encoded string')
}
export function giveOrdinalSuffix (i) {
const j = i % 10
const k = i % 100
if (j === 1 && k !== 11) {
return i + 'st'
}
if (j === 2 && k !== 12) {
return i + 'nd'
}
if (j === 3 && k !== 13) {
return i + 'rd'
}
return i + 'th'
}

View File

@ -10,7 +10,7 @@ export const defaultCommentSort = (pinned, bio, createdAt) => {
return 'hot'
}
export const isJob = item => item.maxBid !== null && typeof item.maxBid !== 'undefined'
export const isJob = item => item.subName !== 'jobs'
// a delete directive preceded by a non word character that isn't a backtick
const deletePattern = /\B@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi

View File

@ -550,14 +550,13 @@ export const commentSchema = object({
text: textValidator(MAX_COMMENT_TEXT_LENGTH).required('required')
})
export const jobSchema = object({
export const jobSchema = args => object({
title: titleValidator,
company: string().required('required').trim(),
text: textValidator(MAX_POST_TEXT_LENGTH).required('required'),
url: string()
.or([string().email(), string().url()], 'invalid url or email')
.required('required'),
maxBid: intValidator.min(0, 'must be at least 0').required('required'),
location: string().test(
'no-remote',
"don't write remote, just check the box",
@ -565,7 +564,8 @@ export const jobSchema = object({
.when('remote', {
is: (value) => !value,
then: schema => schema.required('required').trim()
})
}),
...advPostSchemaMembers(args)
})
export const emailSchema = object({
@ -585,9 +585,26 @@ export const amountSchema = object({
amount: intValidator.required('required').positive('must be positive')
})
export const boostValidator = intValidator
.min(BOOST_MULT, `must be at least ${BOOST_MULT}`).test({
name: 'boost',
test: async boost => boost % BOOST_MULT === 0,
message: `must be divisble be ${BOOST_MULT}`
})
export const boostSchema = object({
amount: boostValidator.required('required').positive('must be positive')
})
export const actSchema = object({
sats: intValidator.required('required').positive('must be positive'),
act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS'])
sats: intValidator.required('required').positive('must be positive')
.when(['act'], ([act], schema) => {
if (act === 'BOOST') {
return boostValidator
}
return schema
}),
act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS', 'BOOST'])
})
export const settingsSchema = object().shape({
@ -618,7 +635,7 @@ export const settingsSchema = object().shape({
string().nullable().matches(NOSTR_PUBKEY_HEX, 'must be 64 hex chars'),
string().nullable().matches(NOSTR_PUBKEY_BECH32, 'invalid bech32 encoding')], 'invalid pubkey'),
nostrRelays: array().of(
string().ws()
string().ws().transform(relay => relay.startsWith('wss://') ? relay : `wss://${relay}`)
).max(NOSTR_MAX_RELAY_NUM,
({ max, value }) => `${Math.abs(max - value.length)} too many`),
hideBookmarks: boolean(),

View File

@ -49,6 +49,7 @@ export default function PostEdit ({ ssrData }) {
existingBoost: {
label: 'old boost',
term: `- ${item.boost}`,
op: '-',
modifier: cost => cost - item.boost
}
}

View File

@ -23,12 +23,15 @@ import { useMemo } from 'react'
import { CompactLongCountdown } from '@/components/countdown'
import { usePaidMutation } from '@/components/use-paid-mutation'
import { DONATE } from '@/fragments/paidAction'
import { ITEM_FULL_FIELDS } from '@/fragments/items'
import { ListItem } from '@/components/items'
const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), {
loading: () => <GrowthPieChartSkeleton />
})
const REWARDS_FULL = gql`
${ITEM_FULL_FIELDS}
{
rewards {
total
@ -37,6 +40,9 @@ const REWARDS_FULL = gql`
name
value
}
ad {
...ItemFullFields
}
leaderboard {
users {
id
@ -97,7 +103,7 @@ export default function Rewards ({ ssrData }) {
const { data } = useQuery(REWARDS_FULL)
const dat = useData(data, ssrData)
let { rewards: [{ total, sources, time, leaderboard }] } = useMemo(() => {
let { rewards: [{ total, sources, time, leaderboard, ad }] } = useMemo(() => {
return dat || { rewards: [{}] }
}, [dat])
@ -124,12 +130,13 @@ export default function Rewards ({ ssrData }) {
return (
<Layout footerLinks>
<h4 className='pt-3 align-self-center text-reset'>
<small className='text-muted'>rewards are sponsored by ...</small>
<Link className='text-reset ms-2' href='/items/141924' style={{ lineHeight: 1.5, textDecoration: 'underline' }}>
SN is hiring
</Link>
</h4>
{ad &&
<div className='pt-3 align-self-center' style={{ maxWidth: '480px', width: '100%' }}>
<div className='fw-bold text-muted pb-2'>
top boost this month
</div>
<ListItem item={ad} />
</div>}
<Row className='pb-3'>
<Col lg={leaderboard?.users && 5}>
<div

View File

@ -170,7 +170,9 @@ export default function Settings ({ ssrData }) {
}
}
const nostrRelaysFiltered = nostrRelays?.filter(word => word.trim().length > 0)
const nostrRelaysFiltered = nostrRelays
?.filter(word => word.trim().length > 0)
.map(relay => relay.startsWith('wss://') ? relay : `wss://${relay}`)
try {
await setSettings({

View File

@ -2,16 +2,13 @@
margin: 1rem 0;
justify-content: start;
font-size: 110%;
gap: 0 0.5rem;
}
.nav :global .nav-link {
padding-left: 0;
}
.nav :global .nav-item:not(:first-child) {
margin-left: 1rem;
}
.nav :global .active {
border-bottom: 2px solid var(--bs-primary);
}

View File

@ -0,0 +1,52 @@
UPDATE "Sub" SET "rewardsPct" = 30;
-- account for comments in rewards
CREATE OR REPLACE FUNCTION rewards(min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT)
RETURNS TABLE (
t TIMESTAMP(3), total BIGINT, donations BIGINT, fees BIGINT, boost BIGINT, jobs BIGINT, anons_stack BIGINT
)
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
RETURN QUERY
SELECT period.t,
coalesce(FLOOR(sum(msats)), 0)::BIGINT as total,
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'DONATION')), 0)::BIGINT as donations,
coalesce(FLOOR(sum(msats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION', 'ANON'))), 0)::BIGINT as fees,
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'BOOST')), 0)::BIGINT as boost,
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'STREAM')), 0)::BIGINT as jobs,
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'ANON')), 0)::BIGINT as anons_stack
FROM generate_series(min, max, ival) period(t),
LATERAL
(
(SELECT
("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) * COALESCE("Sub"."rewardsPct", 100) * 0.01 as msats,
act::text as type
FROM "ItemAct"
JOIN "Item" ON "Item"."id" = "ItemAct"."itemId"
LEFT JOIN "Item" root ON "Item"."rootId" = root.id
JOIN "Sub" ON "Sub"."name" = COALESCE(root."subName", "Item"."subName")
LEFT JOIN "ReferralAct" ON "ReferralAct"."itemActId" = "ItemAct".id
WHERE date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
AND "ItemAct".act <> 'TIP'
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'))
UNION ALL
(SELECT sats * 1000 as msats, 'DONATION' as type
FROM "Donation"
WHERE date_trunc(date_part, "Donation".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t)
UNION ALL
-- any earnings from anon's stack that are not forwarded to other users
(SELECT "ItemAct".msats, 'ANON' as type
FROM "Item"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id
WHERE "Item"."userId" = 27 AND "ItemAct".act = 'TIP'
AND date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
GROUP BY "ItemAct".id, "ItemAct".msats
HAVING COUNT("ItemForward".id) = 0)
) x
GROUP BY period.t;
END;
$$;

View File

@ -0,0 +1,54 @@
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "oldBoost" INTEGER NOT NULL DEFAULT 0;
CREATE OR REPLACE FUNCTION expire_boost_jobs()
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
SELECT 'expireBoost', jsonb_build_object('id', "Item".id), 21, true, now(), interval '1 days'
FROM "Item"
WHERE "Item".boost > 0 ON CONFLICT DO NOTHING;
return 0;
EXCEPTION WHEN OTHERS THEN
return 0;
END;
$$;
SELECT expire_boost_jobs();
DROP FUNCTION IF EXISTS expire_boost_jobs;
-- fold all STREAM "ItemAct" into a single row per item (it's defunct)
INSERT INTO "ItemAct" (created_at, updated_at, msats, act, "itemId", "userId")
SELECT MAX("ItemAct".created_at), MAX("ItemAct".updated_at), sum("ItemAct".msats), 'BOOST', "ItemAct"."itemId", "ItemAct"."userId"
FROM "ItemAct"
WHERE "ItemAct".act = 'STREAM'
GROUP BY "ItemAct"."itemId", "ItemAct"."userId";
-- drop all STREAM "ItemAct" rows
DELETE FROM "ItemAct"
WHERE "ItemAct".act = 'STREAM';
-- AlterEnum
ALTER TYPE "InvoiceActionType" ADD VALUE 'BOOST';
-- increase boost per vote
CREATE OR REPLACE VIEW zap_rank_personal_constants AS
SELECT
10000.0 AS boost_per_vote,
1.2 AS vote_power,
1.3 AS vote_decay,
3.0 AS age_wait_hours,
0.5 AS comment_scaler,
1.2 AS boost_power,
1.6 AS boost_decay,
616 AS global_viewer_id,
interval '7 days' AS item_age_bound,
interval '7 days' AS user_last_seen_bound,
0.9 AS max_personal_viewer_vote_ratio,
0.1 AS min_viewer_votes;
DROP FUNCTION IF EXISTS run_auction(item_id INTEGER);

View File

@ -457,6 +457,7 @@ model Item {
company String?
weightedVotes Float @default(0)
boost Int @default(0)
oldBoost Int @default(0)
pollCost Int?
paidImgLink Boolean @default(false)
commentMsats BigInt @default(0)
@ -804,6 +805,7 @@ enum InvoiceActionType {
ITEM_UPDATE
ZAP
DOWN_ZAP
BOOST
DONATE
POLL_VOTE
TERRITORY_CREATE

View File

@ -11,7 +11,6 @@ const ITEMS = gql`
ncomments
sats
company
maxBid
status
location
remote
@ -232,7 +231,7 @@ ${topCowboys.map((user, i) =>
------
##### Promoted jobs
${jobs.data.items.items.filter(i => i.maxBid > 0 && i.status === 'ACTIVE').slice(0, 5).map((item, i) =>
${jobs.data.items.items.filter(i => i.boost > 0).slice(0, 5).map((item, i) =>
`${i + 1}. [${item.title.trim()} \\ ${item.company} \\ ${item.location}${item.remote ? ' or Remote' : ''}](https://stacker.news/items/${item.id})\n`).join('')}
[**all jobs**](https://stacker.news/~jobs)

View File

@ -245,6 +245,14 @@ $zindex-sticky: 900;
width: fit-content;
}
.line-height-sm {
line-height: 1.25;
}
.line-height-md {
line-height: 1.5;
}
@media (display-mode: standalone) {
.standalone {
display: flex

View File

@ -10,7 +10,7 @@ const MAX_OUTGOING_CLTV_DELTA = 500 // the maximum cltv delta we'll allow for th
export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice
const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request
const MAX_FEE_ESTIMATE_PERCENT = 0.025 // the maximum fee relative to outgoing we'll allow for the fee estimate
const ZAP_SYBIL_FEE_MULT = 10 / 9 // the fee for the zap sybil service
const ZAP_SYBIL_FEE_MULT = 10 / 7 // the fee for the zap sybil service
/*
The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice.

View File

@ -1,22 +0,0 @@
import serialize from '@/api/resolvers/serial.js'
export async function auction ({ models }) {
// get all items we need to check
const items = await models.item.findMany(
{
where: {
maxBid: {
not: null
},
status: {
not: 'STOPPED'
}
}
}
)
// for each item, run serialized auction function
items.forEach(async item => {
await serialize(models.$executeRaw`SELECT run_auction(${item.id}::INTEGER)`, { models })
})
}

27
worker/expireBoost.js Normal file
View File

@ -0,0 +1,27 @@
import { Prisma } from '@prisma/client'
export async function expireBoost ({ data: { id }, models }) {
// reset boost 30 days after last boost
// run in serializable because we use an aggregate here
// and concurrent boosts could be double counted
// serialization errors will cause pgboss retries
await models.$transaction(
[
models.$executeRaw`
WITH boost AS (
SELECT sum(msats) FILTER (WHERE created_at <= now() - interval '30 days') as old_msats,
sum(msats) FILTER (WHERE created_at > now() - interval '30 days') as cur_msats
FROM "ItemAct"
WHERE act = 'BOOST'
AND "itemId" = ${Number(id)}::INTEGER
)
UPDATE "Item"
SET boost = COALESCE(boost.cur_msats, 0), "oldBoost" = COALESCE(boost.old_msats, 0)
FROM boost
WHERE "Item".id = ${Number(id)}::INTEGER`
],
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable
}
)
}

View File

@ -9,7 +9,6 @@ import {
} from './wallet.js'
import { repin } from './repin.js'
import { trust } from './trust.js'
import { auction } from './auction.js'
import { earn } from './earn.js'
import apolloClient from '@apollo/client'
import { indexItem, indexAllItems } from './search.js'
@ -35,6 +34,7 @@ import {
import { thisDay } from './thisDay.js'
import { isServiceEnabled } from '@/lib/sndev.js'
import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts.js'
import { expireBoost } from './expireBoost.js'
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
@ -117,12 +117,12 @@ async function work () {
await boss.work('imgproxy', jobWrapper(imgproxy))
await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages))
}
await boss.work('expireBoost', jobWrapper(expireBoost))
await boss.work('weeklyPost-*', jobWrapper(weeklyPost))
await boss.work('payWeeklyPostBounty', jobWrapper(payWeeklyPostBounty))
await boss.work('repin-*', jobWrapper(repin))
await boss.work('trust', jobWrapper(trust))
await boss.work('timestampItem', jobWrapper(timestampItem))
await boss.work('auction', jobWrapper(auction))
await boss.work('earn', jobWrapper(earn))
await boss.work('streak', jobWrapper(computeStreaks))
await boss.work('checkStreak', jobWrapper(checkStreak))

View File

@ -22,7 +22,6 @@ const ITEM_SEARCH_FIELDS = gql`
subName
}
status
maxBid
company
location
remote