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_BILLING from './territoryBilling'
import * as TERRITORY_UNARCHIVE from './territoryUnarchive' import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate' import * as DONATE from './donate'
import * as BOOST from './boost'
import wrapInvoice from 'wallets/wrap' import wrapInvoice from 'wallets/wrap'
import { createInvoice as createUserInvoice } from 'wallets/server' import { createInvoice as createUserInvoice } from 'wallets/server'
@ -21,6 +22,7 @@ export const paidActions = {
ITEM_UPDATE, ITEM_UPDATE,
ZAP, ZAP,
DOWN_ZAP, DOWN_ZAP,
BOOST,
POLL_VOTE, POLL_VOTE,
TERRITORY_CREATE, TERRITORY_CREATE,
TERRITORY_UPDATE, TERRITORY_UPDATE,
@ -186,7 +188,12 @@ export async function retryPaidAction (actionType, args, context) {
context.optimistic = true context.optimistic = true
context.me = await models.user.findUnique({ where: { id: me.id } }) 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.cost = BigInt(msatsRequested)
context.actionId = actionId context.actionId = actionId
const invoiceArgs = await createSNInvoice(actionType, args, context) const invoiceArgs = await createSNInvoice(actionType, args, context)
@ -245,8 +252,8 @@ export async function createLightningInvoice (actionType, args, context) {
try { try {
const description = await paidActions[actionType].describe(args, context) const description = await paidActions[actionType].describe(args, context)
const { invoice: bolt11, wallet } = await createUserInvoice(userId, { const { invoice: bolt11, wallet } = await createUserInvoice(userId, {
// this is the amount the stacker will receive, the other 1/10th is the fee // this is the amount the stacker will receive, the other 3/10ths is the sybil fee
msats: cost * BigInt(9) / BigInt(10), msats: cost * BigInt(7) / BigInt(10),
description, description,
expiry: INVOICE_EXPIRE_SECS expiry: INVOICE_EXPIRE_SECS
}, { models }) }, { models })

View File

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

View File

@ -13,7 +13,7 @@ export async function getCost ({ id, boost = 0, uploadIds }, { me, models }) {
// or more boost // or more boost
const old = await models.item.findUnique({ where: { id: parseInt(id) } }) const old = await models.item.findUnique({ where: { id: parseInt(id) } })
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me }) 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) { 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 = [] const itemActs = []
if (boostMsats > 0) { if (newBoost > 0) {
const boostMsats = satsToMsats(newBoost)
itemActs.push({ itemActs.push({
msats: boostMsats, act: 'BOOST', userId: me?.id || USER_ID.anon msats: boostMsats, act: 'BOOST', userId: me?.id || USER_ID.anon
}) })
@ -54,15 +55,19 @@ export async function perform (args, context) {
data: { paid: true } 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({ const item = await tx.item.update({
where: { id: parseInt(id) }, where: { id: parseInt(id), boost: old.boost },
include: { include: {
mentions: true, mentions: true,
itemReferrers: { include: { refereeItem: true } } itemReferrers: { include: { refereeItem: true } }
}, },
data: { data: {
...data, ...data,
boost, boost: {
increment: newBoost
},
pollOptions: { pollOptions: {
createMany: { createMany: {
data: pollOptions?.map(option => ({ option })) 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) await tx.$executeRaw`
VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true, now() + interval '5 seconds')` 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) 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 }) { 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 const zapMsats = cost - feeMsats
itemId = parseInt(itemId) itemId = parseInt(itemId)

View File

@ -6,8 +6,9 @@ import domino from 'domino'
import { import {
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD, ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
USER_ID, POLL_COST, USER_ID, POLL_COST, ADMIN_ITEMS, GLOBAL_SEED,
ADMIN_ITEMS, GLOBAL_SEED, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS,
BOOST_MULT
} from '@/lib/constants' } from '@/lib/constants'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts' import { parse } from 'tldts'
@ -30,13 +31,13 @@ function commentsOrderByClause (me, models, sort) {
if (me && sort === 'hot') { if (me && sort === 'hot') {
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, COALESCE( return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, COALESCE(
personal_hot_score, 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` "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
} else { } else {
if (sort === 'top') { 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 { } 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 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) => { const orderByClause = (by, me, models, type) => {
switch (by) { switch (by) {
case 'comments': 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 return `(CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0 THEN
GREATEST("Item"."weightedVotes" - "Item"."weightedDownVotes", POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", 1.2)) GREATEST("Item"."weightedVotes" - "Item"."weightedDownVotes", POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", 1.2))
ELSE ELSE
"Item"."weightedVotes" - "Item"."weightedDownVotes" "Item"."weightedVotes" - "Item"."weightedDownVotes"
END + "Item"."weightedComments"*${commentScaler})` END + "Item"."weightedComments"*${commentScaler}) + ${considerBoost ? `("Item".boost / ${BOOST_MULT})` : 0}`
} }
export function joinZapRankPersonalView (me, models) { 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 }) => { items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit = LIMIT }, { me, models }) => {
const decodedCursor = decodeCursor(cursor) 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 // special authorization for bookmarks depending on owning users' privacy settings
if (type === 'bookmarks' && name && me?.name !== name) { if (type === 'bookmarks' && name && me?.name !== name) {
@ -442,77 +466,26 @@ export default {
models, models,
query: ` query: `
${SELECT}, ${SELECT},
CASE WHEN status = 'ACTIVE' AND "maxBid" > 0 (boost IS NOT NULL AND boost > 0)::INT AS group_rank,
THEN 0 ELSE 1 END AS group_rank, CASE WHEN boost IS NOT NULL AND boost > 0
CASE WHEN status = 'ACTIVE' AND "maxBid" > 0 THEN rank() OVER (ORDER BY boost DESC, created_at ASC)
THEN rank() OVER (ORDER BY "maxBid" DESC, created_at ASC)
ELSE rank() OVER (ORDER BY created_at DESC) END AS rank ELSE rank() OVER (ORDER BY created_at DESC) END AS rank
FROM "Item" FROM "Item"
${whereClause( ${whereClause(
'"parentId" IS NULL', '"parentId" IS NULL',
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
'"Item"."status" = \'ACTIVE\'',
'created_at <= $1', 'created_at <= $1',
'"pinId" IS NULL', '"pinId" IS NULL',
subClause(sub, 4), subClause(sub, 4)
"status IN ('ACTIVE', 'NOSATS')"
)} )}
ORDER BY group_rank, rank ORDER BY group_rank, rank
OFFSET $2 OFFSET $2
LIMIT $3`, LIMIT $3`,
orderBy: 'ORDER BY group_rank, rank' orderBy: 'ORDER BY group_rank DESC, rank'
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr) }, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
break break
default: default:
items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}, ${me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score'} AS rank
FROM "Item"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${joinZapRankPersonalView(me, models)}
${whereClause(
// in "home" (sub undefined), we want to show pinned items (but without the pin icon)
sub ? '"Item"."pinId" IS NULL' : '',
'"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL',
'"Item".outlawed = false',
'"Item".bio = false',
activeOrMine(me),
subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me))}
ORDER BY rank DESC
OFFSET $1
LIMIT $2`,
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) {
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), we want to show pinned items (but without the pin icon)
sub ? '"Item"."pinId" IS NULL' : '',
'"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL',
'"Item".bio = false',
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
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`
}, decodedCursor.offset, limit, ...subArr)
}
if (decodedCursor.offset === 0) { if (decodedCursor.offset === 0) {
// get pins for the page and return those separately // get pins for the page and return those separately
pins = await itemQueryWithMeta({ pins = await itemQueryWithMeta({
@ -537,6 +510,62 @@ export default {
ORDER BY position ASC`, ORDER BY position ASC`,
orderBy: 'ORDER BY position ASC' orderBy: 'ORDER BY position ASC'
}, ...subArr) }, ...subArr)
ad = await getAd(parent, { sub, subArr, showNsfw }, { me, models })
}
items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}, ${me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score'} AS rank
FROM "Item"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${joinZapRankPersonalView(me, models)}
${whereClause(
// in "home" (sub undefined), we want to show pinned items (but without the pin icon)
sub ? '"Item"."pinId" IS NULL' : '',
'"Item"."deletedAt" IS NULL',
'"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))}
ORDER BY rank DESC
OFFSET $1
LIMIT $2`,
orderBy: 'ORDER BY rank 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), we want to show pinned items (but without the pin icon)
sub ? '"Item"."pinId" 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
} }
@ -545,7 +574,8 @@ export default {
return { return {
cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null, cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
items, items,
pins pins,
ad
} }
}, },
item: getItem, item: getItem,
@ -615,18 +645,17 @@ export default {
LIMIT 3` LIMIT 3`
}, similar) }, 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() const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
let where let where
if (bid > 0) { if (boost > 0) {
// if there's a bid // if there's boost
// it's ACTIVE and has a larger bid than ours, or has an equal bid and is older // has a larger boost than ours, or has an equal boost and is older
// count items: (bid > ours.bid OR (bid = ours.bid AND create_at < ours.created_at)) AND status = 'ACTIVE' // count items: (boost > ours.boost OR (boost = ours.boost AND create_at < ours.created_at))
where = { where = {
status: 'ACTIVE',
OR: [ OR: [
{ maxBid: { gt: bid } }, { boost: { gt: boost } },
{ maxBid: bid, createdAt: { lt: createdAt } } { boost, createdAt: { lt: createdAt } }
] ]
} }
} else { } else {
@ -635,18 +664,42 @@ export default {
// count items: ((bid > ours.bid AND status = 'ACTIVE') OR (created_at > ours.created_at AND status <> 'STOPPED')) // count items: ((bid > ours.bid AND status = 'ACTIVE') OR (created_at > ours.created_at AND status <> 'STOPPED'))
where = { where = {
OR: [ OR: [
{ maxBid: { gt: 0 }, status: 'ACTIVE' }, { boost: { gt: 0 } },
{ createdAt: { gt: createdAt }, status: { not: 'STOPPED' } } { 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) { if (id) {
where.id = { not: Number(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 item.uploadId = item.logo
delete item.logo delete item.logo
} }
item.maxBid ??= 0
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd }) return await updateItem(parent, { id, ...item }, { me, models, lnd })
@ -862,7 +914,7 @@ export default {
return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd }) 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 }) assertApiKeyNotPermitted({ me })
await ssValidate(actSchema, { sats, act }) await ssValidate(actSchema, { sats, act })
await assertGofacYourself({ models, headers }) await assertGofacYourself({ models, headers })
@ -881,7 +933,7 @@ export default {
} }
// disallow self tips except anons // disallow self tips except anons
if (me) { if (me && ['TIP', 'DONT_LIKE_THIS'].includes(act)) {
if (Number(item.userId) === Number(me.id)) { if (Number(item.userId) === Number(me.id)) {
throw new GqlInputError('cannot zap yourself') throw new GqlInputError('cannot zap yourself')
} }
@ -899,6 +951,8 @@ export default {
return await performPaidAction('ZAP', { id, sats }, { me, models, lnd }) return await performPaidAction('ZAP', { id, sats }, { me, models, lnd })
} else if (act === 'DONT_LIKE_THIS') { } else if (act === 'DONT_LIKE_THIS') {
return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd }) return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd })
} else if (act === 'BOOST') {
return await performPaidAction('BOOST', { id, sats }, { me, models, lnd })
} else { } else {
throw new GqlInputError('unknown act') 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` } item = { id: Number(item.id), text: item.text, title: `@${user.name}'s bio` }
} else if (old.parentId) { } else if (old.parentId) {
// prevent editing a comment like a post // 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 { } else {
item = { subName, ...item } item = { subName, ...item }
item.forwardUsers = await getForwardUsers(models, forward) item.forwardUsers = await getForwardUsers(models, forward)
@ -1390,5 +1444,5 @@ export const SELECT =
ltree2text("Item"."path") AS "path"` ltree2text("Item"."path") AS "path"`
function topOrderByWeightedSats (me, models) { 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 // territory transfers
queries.push( queries.push(
`(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL as "earnedSats", `(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" = 'ITEM_CREATE' OR
"Invoice"."actionType" = 'ZAP' OR "Invoice"."actionType" = 'ZAP' OR
"Invoice"."actionType" = 'DOWN_ZAP' OR "Invoice"."actionType" = 'DOWN_ZAP' OR
"Invoice"."actionType" = 'POLL_VOTE' "Invoice"."actionType" = 'POLL_VOTE' OR
"Invoice"."actionType" = 'BOOST'
) )
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT})` LIMIT ${LIMIT})`

View File

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

View File

@ -1,5 +1,5 @@
import { amountSchema, ssValidate } from '@/lib/validate' import { amountSchema, ssValidate } from '@/lib/validate'
import { getItem } from './item' import { getAd, getItem } from './item'
import { topUsers } from './user' import { topUsers } from './user'
import performPaidAction from '../paidAction' import performPaidAction from '../paidAction'
import { GqlInputError } from '@/lib/error' import { GqlInputError } from '@/lib/error'
@ -164,6 +164,9 @@ export default {
return 0 return 0
} }
return parent.total return parent.total
},
ad: async (parent, args, { me, models }) => {
return await getAd(parent, { }, { me, models })
} }
}, },
Mutation: { 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) { if (user.noteEarning) {
const earn = await models.earn.findFirst({ const earn = await models.earn.findFirst({
where: { where: {

View File

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

View File

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

View File

@ -19,6 +19,7 @@ export default gql`
time: Date! time: Date!
sources: [NameValue!]! sources: [NameValue!]!
leaderboard: UsersNullable leaderboard: UsersNullable
ad: Item
} }
type Reward { 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 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 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 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 AccordianItem from './accordian-item'
import { Input, InputUserSuggest, VariableInput, Checkbox } from './form' import { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
@ -11,6 +11,8 @@ import { useMe } from './me'
import { useFeeButton } from './fee-button' import { useFeeButton } from './fee-button'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useFormikContext } from 'formik' import { useFormikContext } from 'formik'
import { gql, useLazyQuery } from '@apollo/client'
import useDebounceCallback from './use-debounce-callback'
const EMPTY_FORWARD = { nym: '', pct: '' } const EMPTY_FORWARD = { nym: '', pct: '' }
@ -26,9 +28,118 @@ const FormStatus = {
ERROR: 'error' 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 { me } = useMe()
const { merge } = useFeeButton()
const router = useRouter() const router = useRouter()
const [itemType, setItemType] = useState() const [itemType, setItemType] = useState()
const formik = useFormikContext() const formik = useFormikContext()
@ -111,39 +222,7 @@ export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
body={ body={
<> <>
{children} {children}
<Input <BoostItemInput item={item} sub={sub} />
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>}
/>
<VariableInput <VariableInput
label='forward sats to' label='forward sats to'
name='forward' 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 : null
} }
/> />
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} /> <AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub} />
<ItemButtonBar itemId={item?.id} canDelete={false} /> <ItemButtonBar itemId={item?.id} canDelete={false} />
</Form> </Form>
) )

View File

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

View File

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

View File

@ -17,7 +17,12 @@ export function DownZap ({ item, ...props }) {
} }
: undefined), [meDontLikeSats]) : undefined), [meDontLikeSats])
return ( 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 { try {
showModal(onClose => showModal(onClose =>
<ItemAct <ItemAct
onClose={onClose} item={item} down onClose={onClose} item={item} act='DONT_LIKE_THIS'
> >
<AccordianItem <AccordianItem
header='what is a downzap?' body={ header='what is a downzap?' body={

View File

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

View File

@ -42,7 +42,7 @@ export class SessionRequiredError extends Error {
} }
export function SubmitButton ({ export function SubmitButton ({
children, variant, value, onClick, disabled, appendText, submittingText, children, variant, valueName = 'submit', value, onClick, disabled, appendText, submittingText,
className, ...props className, ...props
}) { }) {
const formik = useFormikContext() const formik = useFormikContext()
@ -58,7 +58,7 @@ export function SubmitButton ({
disabled={disabled} disabled={disabled}
onClick={value onClick={value
? e => { ? e => {
formik.setFieldValue('submit', value) formik.setFieldValue(valueName, value)
onClick && onClick(e) onClick && onClick(e)
} }
: onClick} : onClick}
@ -141,6 +141,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
uploadFees: { uploadFees: {
term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`, term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`,
label: 'upload fee', label: 'upload fee',
op: '+',
modifier: cost => cost + uploadFees.totalFees, modifier: cost => cost + uploadFees.totalFees,
omit: !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 { Form, Input, SubmitButton } from './form'
import { useMe } from './me' import { useMe } from './me'
import UpBolt from '@/svgs/bolt.svg' import UpBolt from '@/svgs/bolt.svg'
import { amountSchema } from '@/lib/validate' import { amountSchema, boostSchema } from '@/lib/validate'
import { useToast } from './toast' import { useToast } from './toast'
import { useLightning } from './lightning' import { useLightning } from './lightning'
import { nextTip, defaultTipIncludingRandom } from './upvote' import { nextTip, defaultTipIncludingRandom } from './upvote'
@ -12,6 +12,7 @@ import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { usePaidMutation } from './use-paid-mutation' import { usePaidMutation } from './use-paid-mutation'
import { ACT_MUTATION } from '@/fragments/paidAction' import { ACT_MUTATION } from '@/fragments/paidAction'
import { meAnonSats } from '@/lib/apollo' import { meAnonSats } from '@/lib/apollo'
import { BoostItemInput } from './adv-post-form'
const defaultTips = [100, 1000, 10_000, 100_000] const defaultTips = [100, 1000, 10_000, 100_000]
@ -53,7 +54,39 @@ const setItemMeAnonSats = ({ id, amount }) => {
window.localStorage.setItem(storageKey, existingAmount + 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 inputRef = useRef(null)
const { me } = useMe() const { me } = useMe()
const [oValue, setOValue] = useState() const [oValue, setOValue] = useState()
@ -62,7 +95,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
inputRef.current?.focus() inputRef.current?.focus()
}, [onClose, item.id]) }, [onClose, item.id])
const act = useAct() const actor = useAct()
const strike = useLightning() const strike = useLightning()
const onSubmit = useCallback(async ({ amount }) => { 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: { variables: {
id: item.id, id: item.id,
sats: Number(amount), sats: Number(amount),
act: down ? 'DONT_LIKE_THIS' : 'TIP' act
}, },
optimisticResponse: me optimisticResponse: me
? { ? {
act: { act: {
__typename: 'ItemActPaidAction', __typename: 'ItemActPaidAction',
result: { 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,13 +134,14 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
}) })
if (error) throw error if (error) throw error
addCustomTip(Number(amount)) addCustomTip(Number(amount))
}, [me, act, down, item.id, onClose, abortSignal, strike]) }, [me, actor, act, item.id, onClose, abortSignal, strike])
return ( return act === 'BOOST'
? <BoostForm step={step} onSubmit={onSubmit} item={item} oValue={oValue} inputRef={inputRef} act={act}>{children}</BoostForm>
: (
<Form <Form
initial={{ initial={{
amount: defaultTipIncludingRandom(me?.privates) || defaultTips[0], amount: defaultTipIncludingRandom(me?.privates) || defaultTips[0]
default: false
}} }}
schema={amountSchema} schema={amountSchema}
onSubmit={onSubmit} onSubmit={onSubmit}
@ -118,19 +152,22 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
type='number' type='number'
innerRef={inputRef} innerRef={inputRef}
overrideValue={oValue} overrideValue={oValue}
step={step}
required required
autoFocus autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/> />
<div> <div>
<Tips setOValue={setOValue} /> <Tips setOValue={setOValue} />
</div> </div>
{children} {children}
<div className='d-flex mt-3'> <div className='d-flex mt-3'>
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton> <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> </div>
</Form> </Form>)
)
} }
function modifyActCache (cache, { result, invoice }) { function modifyActCache (cache, { result, invoice }) {
@ -156,6 +193,12 @@ function modifyActCache (cache, { result, invoice }) {
return existingSats + sats return existingSats + sats
} }
return existingSats return existingSats
},
boost: (existingBoost = 0) => {
if (act === 'BOOST') {
return existingBoost + sats
}
return existingBoost
} }
} }
}) })

View File

@ -1,6 +1,5 @@
import { string } from 'yup' import { string } from 'yup'
import Toc from './table-of-contents' import Toc from './table-of-contents'
import Badge from 'react-bootstrap/Badge'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import Image from 'react-bootstrap/Image' import Image from 'react-bootstrap/Image'
import { SearchTitle } from './item' import { SearchTitle } from './item'
@ -11,6 +10,8 @@ import EmailIcon from '@/svgs/mail-open-line.svg'
import Share from './share' import Share from './share'
import Hat from './hat' import Hat from './hat'
import { MEDIA_URL } from '@/lib/constants' import { MEDIA_URL } from '@/lib/constants'
import { abbrNum } from '@/lib/format'
import { Badge } from 'react-bootstrap'
export default function ItemJob ({ item, toc, rank, children }) { export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = string().email().isValidSync(item.url) const isEmail = string().email().isValidSync(item.url)
@ -50,6 +51,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
</>} </>}
<wbr /> <wbr />
<span> \ </span> <span> \ </span>
{item.boost > 0 && <span>{abbrNum(item.boost)} boost \ </span>}
<span> <span>
<Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'> <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} /> @{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))} {timeSince(new Date(item.createdAt))}
</Link> </Link>
</span> </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 /> <wbr />
<span> \ </span> <span> \ </span>
<Link href={`/items/${item.id}/edit`} className='text-reset'> <Link href={`/items/${item.id}/edit`} className='text-reset fw-bold'>
edit edit
</Link> </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>
</div> </div>
{toc && {toc &&

View File

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

View File

@ -46,6 +46,12 @@ a.title:visited {
margin-bottom: .15rem; margin-bottom: .15rem;
} }
.badge {
vertical-align: middle;
margin-top: -1px;
margin-left: 0.1rem;
}
.newComment { .newComment {
color: var(--theme-grey) !important; color: var(--theme-grey) !important;
background: var(--theme-clickToContextColor) !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 Foooter = Footer || MoreFooter
const dat = useData(data, ssrData) const dat = useData(data, ssrData)
const { items, pins, cursor } = useMemo(() => { const { items, pins, ad, cursor } = useMemo(() => {
if (!dat) return {} if (!dat) return {}
if (destructureData) { if (destructureData) {
return destructureData(dat) return destructureData(dat)
@ -50,6 +50,7 @@ export default function Items ({ ssrData, variables = {}, query, destructureData
return ( return (
<> <>
<div className={styles.grid}> <div className={styles.grid}>
{ad && <ListItem item={ad} ad />}
{itemsWithPins.filter(filter).map((item, i) => ( {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} /> <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 Row from 'react-bootstrap/Row'
import Col from 'react-bootstrap/Col' import Col from 'react-bootstrap/Col'
import InputGroup from 'react-bootstrap/InputGroup'
import Image from 'react-bootstrap/Image' import Image from 'react-bootstrap/Image'
import BootstrapForm from 'react-bootstrap/Form'
import Alert from 'react-bootstrap/Alert'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Info from './info' import Info from './info'
import AccordianItem from './accordian-item'
import styles from '@/styles/post.module.css' import styles from '@/styles/post.module.css'
import { useLazyQuery, gql } from '@apollo/client' import { useLazyQuery, gql } from '@apollo/client'
import Link from 'next/link'
import { usePrice } from './price'
import Avatar from './avatar' import Avatar from './avatar'
import { jobSchema } from '@/lib/validate' import { jobSchema } from '@/lib/validate'
import { MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants' import { BOOST_MIN, BOOST_MULT, MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants'
import { ItemButtonBar } from './post'
import { useFormikContext } from 'formik'
import { UPSERT_JOB } from '@/fragments/paidAction' import { UPSERT_JOB } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit' import useItemSubmit from './use-item-submit'
import { BoostInput } from './adv-post-form'
function satsMin2Mo (minute) { import { numWithUnits, giveOrdinalSuffix } from '@/lib/format'
return minute * 30 * 24 * 60 import useDebounceCallback from './use-debounce-callback'
} import FeeButton from './fee-button'
import CancelButton from './cancel-button'
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>
}
// need to recent list items // need to recent list items
export default function JobForm ({ item, sub }) { export default function JobForm ({ item, sub }) {
const storageKeyPrefix = item ? undefined : `${sub.name}-job` const storageKeyPrefix = item ? undefined : `${sub.name}-job`
const [logoId, setLogoId] = useState(item?.uploadId) 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 extraValues = logoId ? { logo: Number(logoId) } : {}
const onSubmit = useItemSubmit(UPSERT_JOB, { item, sub, extraValues }) const onSubmit = useItemSubmit(UPSERT_JOB, { item, sub, extraValues })
@ -53,13 +48,13 @@ export default function JobForm ({ item, sub }) {
company: item?.company || '', company: item?.company || '',
location: item?.location || '', location: item?.location || '',
remote: item?.remote || false, remote: item?.remote || false,
boost: item?.boost || '',
text: item?.text || '', text: item?.text || '',
url: item?.url || '', url: item?.url || '',
maxBid: item?.maxBid || 0,
stop: false, stop: false,
start: false start: false
}} }}
schema={jobSchema} schema={jobSchema({ existingBoost: item?.boost })}
storageKeyPrefix={storageKeyPrefix} storageKeyPrefix={storageKeyPrefix}
requireSession requireSession
onSubmit={onSubmit} onSubmit={onSubmit}
@ -115,130 +110,46 @@ export default function JobForm ({ item, sub }) {
required required
clear clear
/> />
<PromoteJob item={item} sub={sub} /> <BoostInput
{item && <StatusControl item={item} />} label={
<ItemButtonBar itemId={item?.id} canDelete={false} /> <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> </Form>
</> </>
) )
} }
const FormStatus = { export function JobButtonBar ({
DIRTY: 'dirty', itemId, disable, className, children, handleStop, onCancel, hasCancel = true,
ERROR: 'error' createText = 'post', editText = 'save', stopText = 'remove'
} }) {
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])
return ( return (
<AccordianItem <div className={`mt-3 ${className}`}>
show={show} <div className='d-flex justify-content-between'>
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>} {itemId &&
body={ <SubmitButton valueName='status' value='STOPPED' variant='grey-medium'>{stopText}</SubmitButton>}
<> {children}
<Input <div className='d-flex align-items-center ms-auto'>
label={ {hasCancel && <CancelButton onClick={onCancel} />}
<div className='d-flex align-items-center'>bid <FeeButton
<Info> text={itemId ? editText : createText}
<ol> variant='secondary'
<li>The higher your bid the higher your job will rank</li> disabled={disable}
<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> </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='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> </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 <MarkdownInput
label='context' label='context'
name='text' name='text'

View File

@ -401,12 +401,13 @@ export function Sorts ({ sub, prefix, className }) {
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link> <Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
</Link> </Link>
</Nav.Item> </Nav.Item>
{sub !== 'jobs' &&
<>
<Nav.Item className={className}> <Nav.Item className={className}>
<Link href={prefix + '/random'} passHref legacyBehavior> <Link href={prefix + '/random'} passHref legacyBehavior>
<Nav.Link eventKey='random' className={styles.navLink}>random</Nav.Link> <Nav.Link eventKey='random' className={styles.navLink}>random</Nav.Link>
</Link> </Link>
</Nav.Item> </Nav.Item>
{sub !== 'jobs' &&
<Nav.Item className={className}> <Nav.Item className={className}>
<Link <Link
href={{ href={{
@ -416,7 +417,8 @@ export function Sorts ({ sub, prefix, className }) {
> >
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link> <Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
</Link> </Link>
</Nav.Item>} </Nav.Item>
</>}
</> </>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -180,7 +180,8 @@ function getClient (uri) {
return { return {
cursor: incoming.cursor, cursor: incoming.cursor,
items: [...(existing?.items || []), ...incoming.items], 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 PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']
export const NOFOLLOW_LIMIT = 1000 export const NOFOLLOW_LIMIT = 1000
export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener' 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 = 50 * 1024 * 1024
export const UPLOAD_SIZE_MAX_AVATAR = 5 * 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 export const IMAGE_PIXELS_MAX = 35000000
// backwards compatibile with old media domain env var and precedence for docker url if set // 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}` 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' 'video/webm'
] ]
export const AVATAR_TYPES_ALLOW = UPLOAD_TYPES_ALLOW.filter(t => t.startsWith('image/')) 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_MIN = 1000
export const BOUNTY_MAX = 10000000 export const BOUNTY_MAX = 10000000
export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL'] export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL']
@ -91,16 +91,19 @@ export const TERRITORY_BILLING_OPTIONS = (labelPrefix) => ({
monthly: { monthly: {
term: '+ 100k', term: '+ 100k',
label: `${labelPrefix} month`, label: `${labelPrefix} month`,
op: '+',
modifier: cost => cost + TERRITORY_COST_MONTHLY modifier: cost => cost + TERRITORY_COST_MONTHLY
}, },
yearly: { yearly: {
term: '+ 1m', term: '+ 1m',
label: `${labelPrefix} year`, label: `${labelPrefix} year`,
op: '+',
modifier: cost => cost + TERRITORY_COST_YEARLY modifier: cost => cost + TERRITORY_COST_YEARLY
}, },
once: { once: {
term: '+ 3m', term: '+ 3m',
label: 'one time', label: 'one time',
op: '+',
modifier: cost => cost + TERRITORY_COST_ONCE 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') 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' 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 // 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 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') text: textValidator(MAX_COMMENT_TEXT_LENGTH).required('required')
}) })
export const jobSchema = object({ export const jobSchema = args => object({
title: titleValidator, title: titleValidator,
company: string().required('required').trim(), company: string().required('required').trim(),
text: textValidator(MAX_POST_TEXT_LENGTH).required('required'), text: textValidator(MAX_POST_TEXT_LENGTH).required('required'),
url: string() url: string()
.or([string().email(), string().url()], 'invalid url or email') .or([string().email(), string().url()], 'invalid url or email')
.required('required'), .required('required'),
maxBid: intValidator.min(0, 'must be at least 0').required('required'),
location: string().test( location: string().test(
'no-remote', 'no-remote',
"don't write remote, just check the box", "don't write remote, just check the box",
@ -565,7 +564,8 @@ export const jobSchema = object({
.when('remote', { .when('remote', {
is: (value) => !value, is: (value) => !value,
then: schema => schema.required('required').trim() then: schema => schema.required('required').trim()
}) }),
...advPostSchemaMembers(args)
}) })
export const emailSchema = object({ export const emailSchema = object({
@ -585,9 +585,26 @@ export const amountSchema = object({
amount: intValidator.required('required').positive('must be positive') 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({ export const actSchema = object({
sats: intValidator.required('required').positive('must be positive'), sats: intValidator.required('required').positive('must be positive')
act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS']) .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({ 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_HEX, 'must be 64 hex chars'),
string().nullable().matches(NOSTR_PUBKEY_BECH32, 'invalid bech32 encoding')], 'invalid pubkey'), string().nullable().matches(NOSTR_PUBKEY_BECH32, 'invalid bech32 encoding')], 'invalid pubkey'),
nostrRelays: array().of( nostrRelays: array().of(
string().ws() string().ws().transform(relay => relay.startsWith('wss://') ? relay : `wss://${relay}`)
).max(NOSTR_MAX_RELAY_NUM, ).max(NOSTR_MAX_RELAY_NUM,
({ max, value }) => `${Math.abs(max - value.length)} too many`), ({ max, value }) => `${Math.abs(max - value.length)} too many`),
hideBookmarks: boolean(), hideBookmarks: boolean(),

View File

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

View File

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

View File

@ -2,16 +2,13 @@
margin: 1rem 0; margin: 1rem 0;
justify-content: start; justify-content: start;
font-size: 110%; font-size: 110%;
gap: 0 0.5rem;
} }
.nav :global .nav-link { .nav :global .nav-link {
padding-left: 0; padding-left: 0;
} }
.nav :global .nav-item:not(:first-child) {
margin-left: 1rem;
}
.nav :global .active { .nav :global .active {
border-bottom: 2px solid var(--bs-primary); 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? company String?
weightedVotes Float @default(0) weightedVotes Float @default(0)
boost Int @default(0) boost Int @default(0)
oldBoost Int @default(0)
pollCost Int? pollCost Int?
paidImgLink Boolean @default(false) paidImgLink Boolean @default(false)
commentMsats BigInt @default(0) commentMsats BigInt @default(0)
@ -804,6 +805,7 @@ enum InvoiceActionType {
ITEM_UPDATE ITEM_UPDATE
ZAP ZAP
DOWN_ZAP DOWN_ZAP
BOOST
DONATE DONATE
POLL_VOTE POLL_VOTE
TERRITORY_CREATE TERRITORY_CREATE

View File

@ -11,7 +11,6 @@ const ITEMS = gql`
ncomments ncomments
sats sats
company company
maxBid
status status
location location
remote remote
@ -232,7 +231,7 @@ ${topCowboys.map((user, i) =>
------ ------
##### Promoted jobs ##### 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('')} `${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) [**all jobs**](https://stacker.news/~jobs)

View File

@ -245,6 +245,14 @@ $zindex-sticky: 900;
width: fit-content; width: fit-content;
} }
.line-height-sm {
line-height: 1.25;
}
.line-height-md {
line-height: 1.5;
}
@media (display-mode: standalone) { @media (display-mode: standalone) {
.standalone { .standalone {
display: flex 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 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 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 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. 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' } from './wallet.js'
import { repin } from './repin.js' import { repin } from './repin.js'
import { trust } from './trust.js' import { trust } from './trust.js'
import { auction } from './auction.js'
import { earn } from './earn.js' import { earn } from './earn.js'
import apolloClient from '@apollo/client' import apolloClient from '@apollo/client'
import { indexItem, indexAllItems } from './search.js' import { indexItem, indexAllItems } from './search.js'
@ -35,6 +34,7 @@ import {
import { thisDay } from './thisDay.js' import { thisDay } from './thisDay.js'
import { isServiceEnabled } from '@/lib/sndev.js' import { isServiceEnabled } from '@/lib/sndev.js'
import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts.js' import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts.js'
import { expireBoost } from './expireBoost.js'
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
@ -117,12 +117,12 @@ async function work () {
await boss.work('imgproxy', jobWrapper(imgproxy)) await boss.work('imgproxy', jobWrapper(imgproxy))
await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages)) await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages))
} }
await boss.work('expireBoost', jobWrapper(expireBoost))
await boss.work('weeklyPost-*', jobWrapper(weeklyPost)) await boss.work('weeklyPost-*', jobWrapper(weeklyPost))
await boss.work('payWeeklyPostBounty', jobWrapper(payWeeklyPostBounty)) await boss.work('payWeeklyPostBounty', jobWrapper(payWeeklyPostBounty))
await boss.work('repin-*', jobWrapper(repin)) await boss.work('repin-*', jobWrapper(repin))
await boss.work('trust', jobWrapper(trust)) await boss.work('trust', jobWrapper(trust))
await boss.work('timestampItem', jobWrapper(timestampItem)) await boss.work('timestampItem', jobWrapper(timestampItem))
await boss.work('auction', jobWrapper(auction))
await boss.work('earn', jobWrapper(earn)) await boss.work('earn', jobWrapper(earn))
await boss.work('streak', jobWrapper(computeStreaks)) await boss.work('streak', jobWrapper(computeStreaks))
await boss.work('checkStreak', jobWrapper(checkStreak)) await boss.work('checkStreak', jobWrapper(checkStreak))

View File

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