diff --git a/api/paidAction/boost.js b/api/paidAction/boost.js
new file mode 100644
index 000000000..eed017d69
--- /dev/null
+++ b/api/paidAction/boost.js
@@ -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: { in: 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}`
+}
diff --git a/api/paidAction/index.js b/api/paidAction/index.js
index 7717ba407..27fc7edb9 100644
--- a/api/paidAction/index.js
+++ b/api/paidAction/index.js
@@ -13,6 +13,7 @@ import * as TERRITORY_UPDATE from './territoryUpdate'
import * as TERRITORY_BILLING from './territoryBilling'
import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate'
+import * as BOOST from './boost'
import wrapInvoice from 'wallets/wrap'
import { createInvoice as createUserInvoice } from 'wallets/server'
@@ -21,6 +22,7 @@ export const paidActions = {
ITEM_UPDATE,
ZAP,
DOWN_ZAP,
+ BOOST,
POLL_VOTE,
TERRITORY_CREATE,
TERRITORY_UPDATE,
@@ -186,7 +188,12 @@ export async function retryPaidAction (actionType, args, context) {
context.optimistic = true
context.me = await models.user.findUnique({ where: { id: me.id } })
- const { msatsRequested, actionId } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
+ const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
+ if (!failedInvoice) {
+ throw new Error(`retryPaidAction - invoice not found or not in failed state ${actionType}`)
+ }
+
+ const { msatsRequested, actionId } = failedInvoice
context.cost = BigInt(msatsRequested)
context.actionId = actionId
const invoiceArgs = await createSNInvoice(actionType, args, context)
diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js
index 40c0b0bbb..edc536eca 100644
--- a/api/paidAction/itemCreate.js
+++ b/api/paidAction/itemCreate.js
@@ -195,6 +195,13 @@ export async function onPaid ({ invoice, id }, context) {
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', ${item.id}::INTEGER), 21, true, now() + interval '5 seconds')`
+ if (item.boost > 0) {
+ await tx.$executeRaw`
+ INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
+ VALUES ('expireBoost', jsonb_build_object('id', ${item.id}::INTEGER), 21, true,
+ now() + interval '30 days', interval '40 days')`
+ }
+
if (item.parentId) {
// denormalize ncomments, lastCommentAt, and "weightedComments" for ancestors, and insert into reply table
await tx.$executeRaw`
diff --git a/api/paidAction/itemUpdate.js b/api/paidAction/itemUpdate.js
index 58f20631f..eec7a5588 100644
--- a/api/paidAction/itemUpdate.js
+++ b/api/paidAction/itemUpdate.js
@@ -13,7 +13,7 @@ export async function getCost ({ id, boost = 0, uploadIds }, { me, models }) {
// or more boost
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
- return BigInt(totalFeesMsats) + satsToMsats(boost - (old.boost || 0))
+ return BigInt(totalFeesMsats) + satsToMsats(boost - old.boost)
}
export async function perform (args, context) {
@@ -30,9 +30,10 @@ export async function perform (args, context) {
}
})
- const boostMsats = satsToMsats(boost - (old.boost || 0))
+ const newBoost = boost - old.boost
const itemActs = []
- if (boostMsats > 0) {
+ if (newBoost > 0) {
+ const boostMsats = satsToMsats(newBoost)
itemActs.push({
msats: boostMsats, act: 'BOOST', userId: me?.id || USER_ID.anon
})
@@ -54,15 +55,19 @@ export async function perform (args, context) {
data: { paid: true }
})
+ // we put boost in the where clause because we don't want to update the boost
+ // if it has changed concurrently
const item = await tx.item.update({
- where: { id: parseInt(id) },
+ where: { id: parseInt(id), boost: old.boost },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } }
},
data: {
...data,
- boost,
+ boost: {
+ increment: newBoost
+ },
pollOptions: {
createMany: {
data: pollOptions?.map(option => ({ option }))
@@ -126,8 +131,17 @@ export async function perform (args, context) {
}
})
- await tx.$executeRaw`INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
- VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true, now() + interval '5 seconds')`
+ await tx.$executeRaw`
+ INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
+ VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true,
+ now() + interval '5 seconds', interval '1 day')`
+
+ if (newBoost > 0) {
+ await tx.$executeRaw`
+ INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
+ VALUES ('expireBoost', jsonb_build_object('id', ${id}::INTEGER), 21, true,
+ now() + interval '30 days', interval '40 days')`
+ }
await performBotBehavior(args, context)
diff --git a/api/resolvers/item.js b/api/resolvers/item.js
index 5b283fdf7..fc8a1b03f 100644
--- a/api/resolvers/item.js
+++ b/api/resolvers/item.js
@@ -6,8 +6,9 @@ import domino from 'domino'
import {
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
- USER_ID, POLL_COST,
- ADMIN_ITEMS, GLOBAL_SEED, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS
+ USER_ID, POLL_COST, ADMIN_ITEMS, GLOBAL_SEED,
+ NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS,
+ BOOST_MULT
} from '@/lib/constants'
import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts'
@@ -30,13 +31,13 @@ function commentsOrderByClause (me, models, sort) {
if (me && sort === 'hot') {
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, COALESCE(
personal_hot_score,
- ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST,
+ ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST,
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
} else {
if (sort === 'top') {
- return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator(models, 0)} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
+ return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0 })} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
} else {
- return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
+ return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
}
}
}
@@ -73,6 +74,29 @@ export async function getItem (parent, { id }, { me, models }) {
return item
}
+export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { me, models }) {
+ return (await itemQueryWithMeta({
+ me,
+ models,
+ query: `
+ ${SELECT}
+ FROM "Item"
+ LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
+ ${whereClause(
+ '"parentId" IS NULL',
+ '"Item"."pinId" IS NULL',
+ '"Item"."deletedAt" IS NULL',
+ '"Item"."parentId" IS NULL',
+ '"Item".bio = false',
+ '"Item".boost > 0',
+ activeOrMine(),
+ subClause(sub, 1, 'Item', me, showNsfw),
+ muteClause(me))}
+ ORDER BY boost desc, "Item".created_at ASC
+ LIMIT 1`
+ }, ...subArr))?.[0] || null
+}
+
const orderByClause = (by, me, models, type) => {
switch (by) {
case 'comments':
@@ -88,12 +112,12 @@ const orderByClause = (by, me, models, type) => {
}
}
-export function orderByNumerator (models, commentScaler = 0.5) {
+export function orderByNumerator ({ models, commentScaler = 0.5, considerBoost = false }) {
return `(CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0 THEN
GREATEST("Item"."weightedVotes" - "Item"."weightedDownVotes", POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", 1.2))
ELSE
"Item"."weightedVotes" - "Item"."weightedDownVotes"
- END + "Item"."weightedComments"*${commentScaler})`
+ END + "Item"."weightedComments"*${commentScaler}) + ${considerBoost ? `("Item".boost / ${BOOST_MULT})` : 0}`
}
export function joinZapRankPersonalView (me, models) {
@@ -304,7 +328,7 @@ export default {
},
items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit = LIMIT }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
- let items, user, pins, subFull, table
+ let items, user, pins, subFull, table, ad
// special authorization for bookmarks depending on owning users' privacy settings
if (type === 'bookmarks' && name && me?.name !== name) {
@@ -442,27 +466,54 @@ export default {
models,
query: `
${SELECT},
- CASE WHEN status = 'ACTIVE' AND "maxBid" > 0
- THEN 0 ELSE 1 END AS group_rank,
- CASE WHEN status = 'ACTIVE' AND "maxBid" > 0
- THEN rank() OVER (ORDER BY "maxBid" DESC, created_at ASC)
+ (boost IS NOT NULL AND boost > 0)::INT AS group_rank,
+ CASE WHEN boost IS NOT NULL AND boost > 0
+ THEN rank() OVER (ORDER BY boost DESC, created_at ASC)
ELSE rank() OVER (ORDER BY created_at DESC) END AS rank
FROM "Item"
${whereClause(
'"parentId" IS NULL',
'"Item"."deletedAt" IS NULL',
+ '"Item"."status" = \'ACTIVE\'',
'created_at <= $1',
'"pinId" IS NULL',
- subClause(sub, 4),
- "status IN ('ACTIVE', 'NOSATS')"
+ subClause(sub, 4)
)}
ORDER BY group_rank, rank
OFFSET $2
LIMIT $3`,
- orderBy: 'ORDER BY group_rank, rank'
+ orderBy: 'ORDER BY group_rank DESC, rank'
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
break
default:
+ if (decodedCursor.offset === 0) {
+ // get pins for the page and return those separately
+ pins = await itemQueryWithMeta({
+ me,
+ models,
+ query: `
+ SELECT rank_filter.*
+ FROM (
+ ${SELECT}, position,
+ rank() OVER (
+ PARTITION BY "pinId"
+ ORDER BY "Item".created_at DESC
+ )
+ FROM "Item"
+ JOIN "Pin" ON "Item"."pinId" = "Pin".id
+ ${whereClause(
+ '"pinId" IS NOT NULL',
+ '"parentId" IS NULL',
+ sub ? '"subName" = $1' : '"subName" IS NULL',
+ muteClause(me))}
+ ) rank_filter WHERE RANK = 1
+ ORDER BY position ASC`,
+ orderBy: 'ORDER BY position ASC'
+ }, ...subArr)
+
+ ad = await getAd(parent, { sub, subArr, showNsfw }, { me, models })
+ }
+
items = await itemQueryWithMeta({
me,
models,
@@ -478,6 +529,7 @@ export default {
'"Item"."parentId" IS NULL',
'"Item".outlawed = false',
'"Item".bio = false',
+ ad ? `"Item".id <> ${ad.id}` : '',
activeOrMine(me),
subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me))}
@@ -487,8 +539,8 @@ export default {
orderBy: 'ORDER BY rank DESC'
}, decodedCursor.offset, limit, ...subArr)
- // XXX this is just for subs that are really empty
- if (decodedCursor.offset === 0 && items.length < limit) {
+ // XXX this is mostly for subs that are really empty
+ if (items.length < limit) {
items = await itemQueryWithMeta({
me,
models,
@@ -504,40 +556,17 @@ export default {
'"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL',
'"Item".bio = false',
+ ad ? `"Item".id <> ${ad.id}` : '',
activeOrMine(me),
await filterClause(me, models, type))}
- ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC
+ ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
+ "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC
OFFSET $1
LIMIT $2`,
- orderBy: `ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
+ orderBy: `ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
+ "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
}, decodedCursor.offset, limit, ...subArr)
}
-
- if (decodedCursor.offset === 0) {
- // get pins for the page and return those separately
- pins = await itemQueryWithMeta({
- me,
- models,
- query: `
- SELECT rank_filter.*
- FROM (
- ${SELECT}, position,
- rank() OVER (
- PARTITION BY "pinId"
- ORDER BY "Item".created_at DESC
- )
- FROM "Item"
- JOIN "Pin" ON "Item"."pinId" = "Pin".id
- ${whereClause(
- '"pinId" IS NOT NULL',
- '"parentId" IS NULL',
- sub ? '"subName" = $1' : '"subName" IS NULL',
- muteClause(me))}
- ) rank_filter WHERE RANK = 1
- ORDER BY position ASC`,
- orderBy: 'ORDER BY position ASC'
- }, ...subArr)
- }
break
}
break
@@ -545,7 +574,8 @@ export default {
return {
cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
items,
- pins
+ pins,
+ ad
}
},
item: getItem,
@@ -615,18 +645,17 @@ export default {
LIMIT 3`
}, similar)
},
- auctionPosition: async (parent, { id, sub, bid }, { models, me }) => {
+ auctionPosition: async (parent, { id, sub, boost }, { models, me }) => {
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
let where
- if (bid > 0) {
- // if there's a bid
- // it's ACTIVE and has a larger bid than ours, or has an equal bid and is older
- // count items: (bid > ours.bid OR (bid = ours.bid AND create_at < ours.created_at)) AND status = 'ACTIVE'
+ if (boost > 0) {
+ // if there's boost
+ // has a larger boost than ours, or has an equal boost and is older
+ // count items: (boost > ours.boost OR (boost = ours.boost AND create_at < ours.created_at))
where = {
- status: 'ACTIVE',
OR: [
- { maxBid: { gt: bid } },
- { maxBid: bid, createdAt: { lt: createdAt } }
+ { boost: { gt: boost } },
+ { boost, createdAt: { lt: createdAt } }
]
}
} else {
@@ -635,18 +664,42 @@ export default {
// count items: ((bid > ours.bid AND status = 'ACTIVE') OR (created_at > ours.created_at AND status <> 'STOPPED'))
where = {
OR: [
- { maxBid: { gt: 0 }, status: 'ACTIVE' },
- { createdAt: { gt: createdAt }, status: { not: 'STOPPED' } }
+ { boost: { gt: 0 } },
+ { createdAt: { gt: createdAt } }
]
}
}
- where.subName = sub
+ where.AND = {
+ subName: sub,
+ status: 'ACTIVE',
+ deletedAt: null
+ }
if (id) {
- where.id = { not: Number(id) }
+ where.AND.id = { not: Number(id) }
}
return await models.item.count({ where }) + 1
+ },
+ boostPosition: async (parent, { id, sub, boost }, { models, me }) => {
+ if (boost <= 0) {
+ throw new GqlInputError('boost must be greater than 0')
+ }
+
+ const where = {
+ boost: { gte: boost },
+ status: 'ACTIVE',
+ deletedAt: null,
+ outlawed: false
+ }
+ if (id) {
+ where.id = { not: Number(id) }
+ }
+
+ return {
+ home: await models.item.count({ where }) === 0,
+ sub: await models.item.count({ where: { ...where, subName: sub } }) === 0
+ }
}
},
@@ -825,7 +878,6 @@ export default {
item.uploadId = item.logo
delete item.logo
}
- item.maxBid ??= 0
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd })
@@ -862,7 +914,7 @@ export default {
return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd })
},
- act: async (parent, { id, sats, act = 'TIP', idempotent }, { me, models, lnd, headers }) => {
+ act: async (parent, { id, sats, act = 'TIP' }, { me, models, lnd, headers }) => {
assertApiKeyNotPermitted({ me })
await ssValidate(actSchema, { sats, act })
await assertGofacYourself({ models, headers })
@@ -881,7 +933,7 @@ export default {
}
// disallow self tips except anons
- if (me) {
+ if (me && ['TIP', 'DONT_LIKE_THIS'].includes(act)) {
if (Number(item.userId) === Number(me.id)) {
throw new GqlInputError('cannot zap yourself')
}
@@ -899,6 +951,8 @@ export default {
return await performPaidAction('ZAP', { id, sats }, { me, models, lnd })
} else if (act === 'DONT_LIKE_THIS') {
return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd })
+ } else if (act === 'BOOST') {
+ return await performPaidAction('BOOST', { id, sats }, { me, models, lnd })
} else {
throw new GqlInputError('unknown act')
}
@@ -1390,5 +1444,5 @@ export const SELECT =
ltree2text("Item"."path") AS "path"`
function topOrderByWeightedSats (me, models) {
- return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC`
+ return `ORDER BY ${orderByNumerator({ models })} DESC NULLS LAST, "Item".id DESC`
}
diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js
index d8cb95d71..9cd3b9c11 100644
--- a/api/resolvers/notifications.js
+++ b/api/resolvers/notifications.js
@@ -179,17 +179,6 @@ export default {
)`
)
- queries.push(
- `(SELECT "Item".id::text, "Item"."statusUpdatedAt" AS "sortTime", NULL as "earnedSats",
- 'JobChanged' AS type
- FROM "Item"
- WHERE "Item"."userId" = $1
- AND "maxBid" IS NOT NULL
- AND "statusUpdatedAt" < $2 AND "statusUpdatedAt" <> created_at
- ORDER BY "sortTime" DESC
- LIMIT ${LIMIT})`
- )
-
// territory transfers
queries.push(
`(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL as "earnedSats",
@@ -354,7 +343,8 @@ export default {
"Invoice"."actionType" = 'ITEM_CREATE' OR
"Invoice"."actionType" = 'ZAP' OR
"Invoice"."actionType" = 'DOWN_ZAP' OR
- "Invoice"."actionType" = 'POLL_VOTE'
+ "Invoice"."actionType" = 'POLL_VOTE' OR
+ "Invoice"."actionType" = 'BOOST'
)
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
diff --git a/api/resolvers/paidAction.js b/api/resolvers/paidAction.js
index 03a2118e7..41b0c253d 100644
--- a/api/resolvers/paidAction.js
+++ b/api/resolvers/paidAction.js
@@ -8,6 +8,7 @@ function paidActionType (actionType) {
return 'ItemPaidAction'
case 'ZAP':
case 'DOWN_ZAP':
+ case 'BOOST':
return 'ItemActPaidAction'
case 'TERRITORY_CREATE':
case 'TERRITORY_UPDATE':
diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js
index a98eb1d4a..ce476f35a 100644
--- a/api/resolvers/rewards.js
+++ b/api/resolvers/rewards.js
@@ -1,5 +1,5 @@
import { amountSchema, ssValidate } from '@/lib/validate'
-import { getItem } from './item'
+import { getAd, getItem } from './item'
import { topUsers } from './user'
import performPaidAction from '../paidAction'
import { GqlInputError } from '@/lib/error'
@@ -164,6 +164,9 @@ export default {
return 0
}
return parent.total
+ },
+ ad: async (parent, args, { me, models }) => {
+ return await getAd(parent, { }, { me, models })
}
},
Mutation: {
diff --git a/api/resolvers/user.js b/api/resolvers/user.js
index 3ca842930..942bf38f6 100644
--- a/api/resolvers/user.js
+++ b/api/resolvers/user.js
@@ -396,22 +396,6 @@ export default {
}
}
- const job = await models.item.findFirst({
- where: {
- maxBid: {
- not: null
- },
- userId: me.id,
- statusUpdatedAt: {
- gt: lastChecked
- }
- }
- })
- if (job && job.statusUpdatedAt > job.createdAt) {
- foundNotes()
- return true
- }
-
if (user.noteEarning) {
const earn = await models.earn.findFirst({
where: {
diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js
index d3315f122..ab2390fff 100644
--- a/api/resolvers/wallet.js
+++ b/api/resolvers/wallet.js
@@ -516,6 +516,7 @@ const resolvers = {
case 'ZAP':
case 'DOWN_ZAP':
case 'POLL_VOTE':
+ case 'BOOST':
return (await itemQueryWithMeta({
me,
models,
@@ -532,12 +533,14 @@ const resolvers = {
const action2act = {
ZAP: 'TIP',
DOWN_ZAP: 'DONT_LIKE_THIS',
- POLL_VOTE: 'POLL'
+ POLL_VOTE: 'POLL',
+ BOOST: 'BOOST'
}
switch (invoice.actionType) {
case 'ZAP':
case 'DOWN_ZAP':
case 'POLL_VOTE':
+ case 'BOOST':
return (await models.$queryRaw`
SELECT id, act, "invoiceId", "invoiceActionState", msats
FROM "ItemAct"
diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js
index 67f5513a5..56046b910 100644
--- a/api/typeDefs/item.js
+++ b/api/typeDefs/item.js
@@ -8,10 +8,16 @@ export default gql`
dupes(url: String!): [Item!]
related(cursor: String, title: String, id: ID, minMatch: String, limit: Limit): Items
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String, from: String, to: String): Items
- auctionPosition(sub: String, id: ID, bid: Int!): Int!
+ auctionPosition(sub: String, id: ID, boost: Int): Int!
+ boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int!
}
+ type BoostPositions {
+ home: Boolean!
+ sub: Boolean!
+ }
+
type TitleUnshorted {
title: String
unshorted: String
@@ -46,7 +52,7 @@ export default gql`
hash: String, hmac: String): ItemPaidAction!
upsertJob(
id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
- text: String!, url: String!, maxBid: Int!, status: String, logo: Int): ItemPaidAction!
+ text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction!
upsertPoll(
id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date,
hash: String, hmac: String): ItemPaidAction!
@@ -79,6 +85,7 @@ export default gql`
cursor: String
items: [Item!]!
pins: [Item!]
+ ad: Item
}
type Comments {
@@ -136,7 +143,6 @@ export default gql`
path: String
position: Int
prior: Int
- maxBid: Int
isJob: Boolean!
pollCost: Int
poll: Poll
diff --git a/api/typeDefs/rewards.js b/api/typeDefs/rewards.js
index ade5fd0f3..6a1044866 100644
--- a/api/typeDefs/rewards.js
+++ b/api/typeDefs/rewards.js
@@ -19,6 +19,7 @@ export default gql`
time: Date!
sources: [NameValue!]!
leaderboard: UsersNullable
+ ad: Item
}
type Reward {
diff --git a/components/adv-post-form.js b/components/adv-post-form.js
index 638507848..660160fe5 100644
--- a/components/adv-post-form.js
+++ b/components/adv-post-form.js
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useMemo } from 'react'
import AccordianItem from './accordian-item'
import { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
import InputGroup from 'react-bootstrap/InputGroup'
@@ -11,6 +11,8 @@ import { useMe } from './me'
import { useFeeButton } from './fee-button'
import { useRouter } from 'next/router'
import { useFormikContext } from 'formik'
+import { gql, useLazyQuery } from '@apollo/client'
+import useDebounceCallback from './use-debounce-callback'
const EMPTY_FORWARD = { nym: '', pct: '' }
@@ -26,9 +28,116 @@ const FormStatus = {
ERROR: 'error'
}
-export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
+export function BoostHelp () {
+ return (
+
+ - Boost ranks items higher based on the amount
+ - The highest boost in a territory over the last 30 days is pinned to the top of the territory
+ - The highest boost across all territories over the last 30 days is pinned to the top of the homepage
+ - The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}
+ - Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker
+
+ - e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker
+
+
+ - The decay of boost "votes" increases at 1.25x the rate of organic votes
+
+ - i.e. boost votes fall out of ranking faster
+
+
+ - boost can take a few minutes to show higher ranking in feed
+ - 100% of boost goes to the territory founder and top stackers as rewards
+
+ )
+}
+
+export function BoostInput ({ onChange, ...props }) {
+ const feeButton = useFeeButton()
+ let merge
+ if (feeButton) {
+ ({ merge } = feeButton)
+ }
+ return (
+ boost
+
+
+
+
+ }
+ name='boost'
+ onChange={(_, e) => {
+ merge?.({
+ boost: {
+ term: `+ ${e.target.value}`,
+ label: 'boost',
+ op: '+',
+ modifier: cost => cost + Number(e.target.value)
+ }
+ })
+ onChange && onChange(_, e)
+ }}
+ hint={ranks posts higher temporarily based on the amount}
+ append={sats}
+ {...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 (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 ')}`
+ } else if (boost >= 0 && boost % BOOST_MULT === 0) {
+ return `${act ? 'brings to' : 'equivalent to'} ${numWithUnits(boost / BOOST_MULT, { unitPlural: 'zapvotes', unitSingular: 'zapvote' })}`
+ } else {
+ return 'ranks posts higher based on the amount'
+ }
+ }, [boost, data?.boostPosition?.home, data?.boostPosition?.sub, item?.subName, sub?.name])
+
+ return (
+ {boostMessage}}
+ onChange={(_, e) => {
+ if (e.target.value >= 0) {
+ setBoost(Number(e.target.value) + (act ? Number(item?.boost) : 0))
+ }
+ }}
+ {...props}
+ />
+ )
+}
+
+export default function AdvPostForm ({ children, item, sub, storageKeyPrefix }) {
const { me } = useMe()
- const { merge } = useFeeButton()
const router = useRouter()
const [itemType, setItemType] = useState()
const formik = useFormikContext()
@@ -111,39 +220,7 @@ export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
body={
<>
{children}
- boost
-
-
- - Boost ranks posts higher temporarily based on the amount
- - The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}
- - Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to one trusted upvote
-
- - e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like 5 votes
-
-
- - The decay of boost "votes" increases at 1.25x the rate of organic votes
-
- - i.e. boost votes fall out of ranking faster
-
-
- - 100% of sats from boost are given back to top stackers as rewards
-
-
-
- }
- name='boost'
- onChange={(_, e) => merge({
- boost: {
- term: `+ ${e.target.value}`,
- label: 'boost',
- modifier: cost => cost + Number(e.target.value)
- }
- })}
- hint={ranks posts higher temporarily based on the amount}
- append={sats}
- />
+
(boost
+ ? {
+ fill: getColor(boost),
+ filter: `drop-shadow(0 0 6px ${getColor(boost)}90)`,
+ transform: 'scaleX(-1)'
+ }
+ : {
+ transform: 'scaleX(-1)'
+ }), [boost])
+ return (
+
+ }
+ />
+ )
+}
+
+function Booster ({ item, As, children }) {
+ const toaster = useToast()
+ const showModal = useShowModal()
+
+ return (
+ {
+ try {
+ showModal(onClose =>
+
+ } />
+ )
+ } catch (error) {
+ toaster.danger('failed to boost item')
+ }
+ }}
+ >
+ {children}
+
+ )
+}
diff --git a/components/bounty-form.js b/components/bounty-form.js
index c48584d5a..a210f0c58 100644
--- a/components/bounty-form.js
+++ b/components/bounty-form.js
@@ -80,7 +80,7 @@ export function BountyForm ({
: null
}
/>
-
+
)
diff --git a/components/comment.js b/components/comment.js
index 5bdabf0b1..6ea35bdfa 100644
--- a/components/comment.js
+++ b/components/comment.js
@@ -25,6 +25,7 @@ import Skull from '@/svgs/death-skull.svg'
import { commentSubTreeRootId } from '@/lib/item'
import Pin from '@/svgs/pushpin-fill.svg'
import LinkToContext from './link-to-context'
+import Boost from './boost-button'
function Parent ({ item, rootText }) {
const root = useRoot()
@@ -144,9 +145,11 @@ export default function Comment ({
{item.outlawed && !me?.privates?.wildWestMode
?
- : item.meDontLikeSats > item.meSats
- ?
- : pin ?
:
}
+ : item.mine
+ ?
+ : item.meDontLikeSats > item.meSats
+ ?
+ : pin ?
:
}
{item.user?.meMute && !includeParent && collapse === 'yep'
diff --git a/components/discussion-form.js b/components/discussion-form.js
index 7ae2f191b..86ca6b75f 100644
--- a/components/discussion-form.js
+++ b/components/discussion-form.js
@@ -79,7 +79,7 @@ export function DiscussionForm ({
?
: null}
/>
-
+
{!item &&
0 ? '' : 'invisible'}`}>
diff --git a/components/dont-link-this.js b/components/dont-link-this.js
index bb5cb761f..ef058887a 100644
--- a/components/dont-link-this.js
+++ b/components/dont-link-this.js
@@ -17,7 +17,12 @@ export function DownZap ({ item, ...props }) {
}
: undefined), [meDontLikeSats])
return (
-
} />
+
+
+
+
}
+ />
)
}
@@ -31,7 +36,7 @@ function DownZapper ({ item, As, children }) {
try {
showModal(onClose =>
cost * ANON_FEE_MULTIPLIER
}
}
@@ -28,6 +29,7 @@ export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me })
baseCost: {
term: baseCost,
label: `${comment ? 'comment' : 'post'} cost`,
+ op: '_',
modifier: (cost) => cost + baseCost,
allowFreebies: comment
},
@@ -48,10 +50,12 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
useEffect(() => {
const repetition = data?.itemRepetition
if (!repetition) return setLine({})
+ console.log('repetition', repetition)
setLine({
itemRepetition: {
term: <>x 10{repetition}>,
label: <>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m>,
+ op: '*',
modifier: (cost) => cost * Math.pow(10, repetition)
}
})
@@ -61,6 +65,35 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
}
}
+function sortHelper (a, b) {
+ if (a.op === '_') {
+ return -1
+ } else if (b.op === '_') {
+ return 1
+ } else if (a.op === '*' || a.op === '/') {
+ if (b.op === '*' || b.op === '/') {
+ return 0
+ }
+ // a is higher precedence
+ return -1
+ } else {
+ if (b.op === '*' || b.op === '/') {
+ // b is higher precedence
+ return 1
+ }
+
+ // postive first
+ if (a.op === '+' && b.op === '-') {
+ return -1
+ }
+ if (a.op === '-' && b.op === '+') {
+ return 1
+ }
+ // both are + or -
+ return 0
+ }
+}
+
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
const [lineItems, setLineItems] = useState({})
const [disabled, setDisabled] = useState(false)
@@ -77,7 +110,7 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = ()
const value = useMemo(() => {
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
- const total = Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0)
+ const total = Object.values(lines).sort(sortHelper).reduce((acc, { modifier }) => modifier(acc), 0)
// freebies: there's only a base cost and we don't have enough sats
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total && !me?.privates?.disableFreebies
return {
@@ -145,7 +178,7 @@ function Receipt ({ lines, total }) {
return (
- {Object.entries(lines).map(([key, { term, label, omit }]) => (
+ {Object.entries(lines).sort(([, a], [, b]) => sortHelper(a, b)).map(([key, { term, label, omit }]) => (
!omit &&
{term} |
diff --git a/components/form.js b/components/form.js
index 7e6b2d416..6f9acc494 100644
--- a/components/form.js
+++ b/components/form.js
@@ -42,7 +42,7 @@ export class SessionRequiredError extends Error {
}
export function SubmitButton ({
- children, variant, value, onClick, disabled, appendText, submittingText,
+ children, variant, valueName = 'submit', value, onClick, disabled, appendText, submittingText,
className, ...props
}) {
const formik = useFormikContext()
@@ -58,7 +58,7 @@ export function SubmitButton ({
disabled={disabled}
onClick={value
? e => {
- formik.setFieldValue('submit', value)
+ formik.setFieldValue(valueName, value)
onClick && onClick(e)
}
: onClick}
@@ -141,6 +141,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
uploadFees: {
term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`,
label: 'upload fee',
+ op: '+',
modifier: cost => cost + uploadFees.totalFees,
omit: !uploadFees.totalFees
}
diff --git a/components/item-act.js b/components/item-act.js
index 4d637a21c..a58a160df 100644
--- a/components/item-act.js
+++ b/components/item-act.js
@@ -4,7 +4,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'
import { Form, Input, SubmitButton } from './form'
import { useMe } from './me'
import UpBolt from '@/svgs/bolt.svg'
-import { amountSchema } from '@/lib/validate'
+import { amountSchema, boostSchema } from '@/lib/validate'
import { useToast } from './toast'
import { useLightning } from './lightning'
import { nextTip, defaultTipIncludingRandom } from './upvote'
@@ -12,6 +12,7 @@ import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { usePaidMutation } from './use-paid-mutation'
import { ACT_MUTATION } from '@/fragments/paidAction'
import { meAnonSats } from '@/lib/apollo'
+import { BoostItemInput } from './adv-post-form'
const defaultTips = [100, 1000, 10_000, 100_000]
@@ -53,7 +54,39 @@ const setItemMeAnonSats = ({ id, amount }) => {
window.localStorage.setItem(storageKey, existingAmount + amount)
}
-export default function ItemAct ({ onClose, item, down, children, abortSignal }) {
+function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'BOOST' }) {
+ return (
+
+ )
+}
+
+export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
const inputRef = useRef(null)
const { me } = useMe()
const [oValue, setOValue] = useState()
@@ -62,7 +95,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
inputRef.current?.focus()
}, [onClose, item.id])
- const act = useAct()
+ const actor = useAct()
const strike = useLightning()
const onSubmit = useCallback(async ({ amount }) => {
@@ -76,18 +109,18 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
}
}
}
- const { error } = await act({
+ const { error } = await actor({
variables: {
id: item.id,
sats: Number(amount),
- act: down ? 'DONT_LIKE_THIS' : 'TIP'
+ act
},
optimisticResponse: me
? {
act: {
__typename: 'ItemActPaidAction',
result: {
- id: item.id, sats: Number(amount), act: down ? 'DONT_LIKE_THIS' : 'TIP', path: item.path
+ id: item.id, sats: Number(amount), act, path: item.path
}
}
}
@@ -101,36 +134,40 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
})
if (error) throw error
addCustomTip(Number(amount))
- }, [me, act, down, item.id, onClose, abortSignal, strike])
+ }, [me, actor, act, item.id, onClose, abortSignal, strike])
- return (
-
- )
+ return act === 'BOOST'
+ ? {children}
+ : (
+ )
}
function modifyActCache (cache, { result, invoice }) {
@@ -156,6 +193,12 @@ function modifyActCache (cache, { result, invoice }) {
return existingSats + sats
}
return existingSats
+ },
+ boost: (existingBoost = 0) => {
+ if (act === 'BOOST') {
+ return existingBoost + sats
+ }
+ return existingBoost
}
}
})
diff --git a/components/item-job.js b/components/item-job.js
index c0ae33f67..b8c7876c4 100644
--- a/components/item-job.js
+++ b/components/item-job.js
@@ -1,6 +1,5 @@
import { string } from 'yup'
import Toc from './table-of-contents'
-import Badge from 'react-bootstrap/Badge'
import Button from 'react-bootstrap/Button'
import Image from 'react-bootstrap/Image'
import { SearchTitle } from './item'
@@ -11,6 +10,8 @@ import EmailIcon from '@/svgs/mail-open-line.svg'
import Share from './share'
import Hat from './hat'
import { MEDIA_URL } from '@/lib/constants'
+import { abbrNum } from '@/lib/format'
+import { Badge } from 'react-bootstrap'
export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = string().email().isValidSync(item.url)
@@ -50,6 +51,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
>}
\
+ {item.boost > 0 && {abbrNum(item.boost)} boost \ }
@{item.user.name}
@@ -59,17 +61,21 @@ export default function ItemJob ({ item, toc, rank, children }) {
{timeSince(new Date(item.createdAt))}
- {item.mine &&
+ {item.subName &&
+
+ {' '}{item.subName}
+ }
+ {item.status === 'STOPPED' &&
+ <>{' '}stopped>}
+ {item.mine && !item.deletedAt &&
(
<>
\
-
+
edit
- {item.status !== 'ACTIVE' && {item.status}}
>)}
- {item.maxBid > 0 && item.status === 'ACTIVE' && PROMOTED}
{toc &&
diff --git a/components/item.js b/components/item.js
index 49a3bde5c..c65083f22 100644
--- a/components/item.js
+++ b/components/item.js
@@ -24,6 +24,7 @@ import removeMd from 'remove-markdown'
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, parseInternalLinks } from '@/lib/url'
import ItemPopover from './item-popover'
import { useMe } from './me'
+import Boost from './boost-button'
function onItemClick (e, router, item) {
const viewedAt = commentsViewedAt(item)
@@ -105,11 +106,13 @@ export default function Item ({
{item.position && (pinnable || !item.subName)
?
- : item.meDontLikeSats > item.meSats
- ?
- : Number(item.user?.id) === USER_ID.ad
- ?
- :
}
+ : item.mine
+ ?
+ : item.meDontLikeSats > item.meSats
+ ?
+ : Number(item.user?.id) === USER_ID.ad
+ ?
+ :
}
{
+ const { items, pins, ad, cursor } = useMemo(() => {
if (!dat) return {}
if (destructureData) {
return destructureData(dat)
@@ -50,6 +50,7 @@ export default function Items ({ ssrData, variables = {}, query, destructureData
return (
<>
+ {ad &&
}
{itemsWithPins.filter(filter).map((item, i) => (
0} />
))}
diff --git a/components/job-form.js b/components/job-form.js
index afb2e028b..f8533586b 100644
--- a/components/job-form.js
+++ b/components/job-form.js
@@ -1,46 +1,41 @@
-import { Checkbox, Form, Input, MarkdownInput } from './form'
+import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form'
import Row from 'react-bootstrap/Row'
import Col from 'react-bootstrap/Col'
-import InputGroup from 'react-bootstrap/InputGroup'
import Image from 'react-bootstrap/Image'
-import BootstrapForm from 'react-bootstrap/Form'
-import Alert from 'react-bootstrap/Alert'
import { useEffect, useState } from 'react'
import Info from './info'
-import AccordianItem from './accordian-item'
import styles from '@/styles/post.module.css'
import { useLazyQuery, gql } from '@apollo/client'
-import Link from 'next/link'
-import { usePrice } from './price'
import Avatar from './avatar'
import { jobSchema } from '@/lib/validate'
-import { MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants'
-import { ItemButtonBar } from './post'
-import { useFormikContext } from 'formik'
+import { BOOST_MIN, BOOST_MULT, MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants'
import { UPSERT_JOB } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'
-
-function satsMin2Mo (minute) {
- return minute * 30 * 24 * 60
-}
-
-function PriceHint ({ monthly }) {
- const { price, fiatSymbol } = usePrice()
-
- if (!price || !monthly) {
- return null
- }
- const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
- const fiat = fixed((price / 100000000) * monthly, 0)
-
- return {monthly} sats/mo which is {fiatSymbol}{fiat}/mo
-}
+import { BoostInput } from './adv-post-form'
+import { numWithUnits, giveOrdinalSuffix } from '@/lib/format'
+import useDebounceCallback from './use-debounce-callback'
+import FeeButton from './fee-button'
+import CancelButton from './cancel-button'
// need to recent list items
export default function JobForm ({ item, sub }) {
const storageKeyPrefix = item ? undefined : `${sub.name}-job`
const [logoId, setLogoId] = useState(item?.uploadId)
+ const [getAuctionPosition, { data }] = useLazyQuery(gql`
+ query AuctionPosition($id: ID, $boost: Int) {
+ auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, boost: $boost)
+ }`,
+ { fetchPolicy: 'cache-and-network' })
+
+ const getPositionDebounce = useDebounceCallback((...args) => getAuctionPosition(...args), 1000, [getAuctionPosition])
+
+ useEffect(() => {
+ if (item?.boost) {
+ getPositionDebounce({ variables: { boost: item.boost, id: item.id } })
+ }
+ }, [item?.boost])
+
const extraValues = logoId ? { logo: Number(logoId) } : {}
const onSubmit = useItemSubmit(UPSERT_JOB, { item, sub, extraValues })
@@ -53,13 +48,13 @@ export default function JobForm ({ item, sub }) {
company: item?.company || '',
location: item?.location || '',
remote: item?.remote || false,
+ boost: item?.boost || '',
text: item?.text || '',
url: item?.url || '',
- maxBid: item?.maxBid || 0,
stop: false,
start: false
}}
- schema={jobSchema}
+ schema={jobSchema({ existingBoost: item?.boost })}
storageKeyPrefix={storageKeyPrefix}
requireSession
onSubmit={onSubmit}
@@ -115,130 +110,46 @@ export default function JobForm ({ item, sub }) {
required
clear
/>
-
- {item && }
-
+ boost
+
+
+ - Boost ranks jobs higher based on the amount
+ - The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}
+ - Boost must be divisible by {numWithUnits(BOOST_MULT, { abbreviate: false })}
+ - 100% of boost goes to the territory founder and top stackers as rewards
+
+
+
+ }
+ hint={
{data?.auctionPosition ? `your job will rank ${giveOrdinalSuffix(data.auctionPosition)}` : 'higher boost ranks your job higher'}}
+ onChange={(_, e) => getPositionDebounce({ variables: { boost: Number(e.target.value), id: item?.id } })}
+ />
+
>
)
}
-const FormStatus = {
- DIRTY: 'dirty',
- ERROR: 'error'
-}
-
-function PromoteJob ({ item, sub }) {
- const formik = useFormikContext()
- const [show, setShow] = useState(false)
- const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0))
- const [getAuctionPosition, { data }] = useLazyQuery(gql`
- query AuctionPosition($id: ID, $bid: Int!) {
- auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, bid: $bid)
- }`,
- { fetchPolicy: 'cache-and-network' })
- const position = data?.auctionPosition
-
- useEffect(() => {
- const initialMaxBid = Number(item?.maxBid) || 0
- getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
- setMonthly(satsMin2Mo(initialMaxBid))
- }, [])
-
- useEffect(() => {
- if (formik?.values?.maxBid !== 0) {
- setShow(FormStatus.DIRTY)
- }
- }, [formik?.values])
-
- useEffect(() => {
- const hasMaxBidError = !!formik?.errors?.maxBid
- // if it's open we don't want to collapse on submit
- setShow(show => show || (hasMaxBidError && formik?.isSubmitting && FormStatus.ERROR))
- }, [formik?.isSubmitting])
-
+export function JobButtonBar ({
+ itemId, disable, className, children, handleStop, onCancel, hasCancel = true,
+ createText = 'post', editText = 'save', stopText = 'remove'
+}) {
return (
-
promote }
- body={
- <>
-
bid
-
-
- - The higher your bid the higher your job will rank
- - You can increase, decrease, or remove your bid at anytime
- - You can edit or stop your job at anytime
- - If you run out of sats, your job will stop being promoted until you fill your wallet again
-
-
-
optional
-
- }
- 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={
sats/min}
- hint={
}
- />
- <>
This bid puts your job in position: {position}
>
- >
- }
- />
- )
-}
-
-function StatusControl ({ item }) {
- let StatusComp
-
- if (item.status !== 'STOPPED') {
- StatusComp = () => {
- return (
- <>
-
-
I want to stop my job }
- headerColor='var(--bs-danger)'
- body={
- stop my job} name='stop' inline
- />
- }
+
+
+ {itemId &&
+
{stopText}}
+ {children}
+
+ {hasCancel &&
}
+
- >
- )
- }
- } else if (item.status === 'STOPPED') {
- StatusComp = () => {
- return (
-
I want to resume my job}
- headerColor='var(--bs-success)'
- body={
-
resume my job} name='start' inline
- />
- }
- />
- )
- }
- }
-
- return (
-
-
-
job control
- {item.status === 'NOSATS' &&
-
your promotion ran out of sats. fund your wallet or reduce bid to continue promoting your job}
-
+
)
diff --git a/components/link-form.js b/components/link-form.js
index bfd65810e..61192486e 100644
--- a/components/link-form.js
+++ b/components/link-form.js
@@ -163,7 +163,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
}
}}
/>
-
+
recent
-
-
- random
-
-
{sub !== 'jobs' &&
-
-
- top
-
- }
+ <>
+
+
+ random
+
+
+
+
+ top
+
+
+ >}
>
)
}
diff --git a/components/notifications.js b/components/notifications.js
index 6e93dcf82..6e05f917e 100644
--- a/components/notifications.js
+++ b/components/notifications.js
@@ -406,9 +406,18 @@ function Invoicification ({ n: { invoice, sortTime } }) {
invoiceId = invoice.item.poll?.meInvoiceId
invoiceActionState = invoice.item.poll?.meInvoiceActionState
} else {
- actionString = `${invoice.actionType === 'ZAP'
- ? invoice.item.root?.bounty ? 'bounty payment' : 'zap'
- : 'downzap'} on ${itemType} `
+ if (invoice.actionType === 'ZAP') {
+ if (invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root.mine) {
+ actionString = 'bounty payment'
+ } else {
+ actionString = 'zap'
+ }
+ } else if (invoice.actionType === 'DOWN_ZAP') {
+ actionString = 'downzap'
+ } else if (invoice.actionType === 'BOOST') {
+ actionString = 'boost'
+ }
+ actionString = `${actionString} on ${itemType} `
retry = actRetry;
({ id: invoiceId, actionState: invoiceActionState } = invoice.itemAct.invoice)
}
diff --git a/components/poll-form.js b/components/poll-form.js
index d55938065..c1e84c1d8 100644
--- a/components/poll-form.js
+++ b/components/poll-form.js
@@ -62,7 +62,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
: null}
maxLength={MAX_POLL_CHOICE_LENGTH}
/>
-
+
router.push(`/items/${itemId}`))}
>
-
+
}
{children}
diff --git a/components/territory-form.js b/components/territory-form.js
index b4fa62785..0c953e719 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -77,6 +77,7 @@ export default function TerritoryForm ({ sub }) {
lines.paid = {
term: `- ${abbrNum(alreadyBilled)} sats`,
label: 'already paid',
+ op: '-',
modifier: cost => cost - alreadyBilled
}
return lines
diff --git a/components/upvote.js b/components/upvote.js
index f30464849..415e922f3 100644
--- a/components/upvote.js
+++ b/components/upvote.js
@@ -12,6 +12,7 @@ import Popover from 'react-bootstrap/Popover'
import { useShowModal } from './modal'
import { numWithUnits } from '@/lib/format'
import { Dropdown } from 'react-bootstrap'
+import classNames from 'classnames'
const UpvotePopover = ({ target, show, handleClose }) => {
const { me } = useMe()
@@ -226,7 +227,14 @@ export default function UpVote ({ item, className }) {
}
}
- const fillColor = hover || pending ? nextColor : color
+ const fillColor = meSats && (hover || pending ? nextColor : color)
+
+ const style = useMemo(() => (fillColor
+ ? {
+ fill: fillColor,
+ filter: `drop-shadow(0 0 6px ${fillColor}90)`
+ }
+ : undefined), [fillColor])
return (
@@ -236,7 +244,7 @@ export default function UpVote ({ item, className }) {
>
setHover(true)}
@@ -244,19 +252,12 @@ export default function UpVote ({ item, className }) {
onTouchEnd={() => setHover(false)}
width={26}
height={26}
- className={
- `${styles.upvote}
- ${className || ''}
- ${disabled ? styles.noSelfTips : ''}
- ${meSats ? styles.voted : ''}
- ${pending ? styles.pending : ''}`
- }
- style={meSats || hover || pending
- ? {
- fill: fillColor,
- filter: `drop-shadow(0 0 6px ${fillColor}90)`
- }
- : undefined}
+ className={classNames(styles.upvote,
+ className,
+ disabled && styles.noSelfTips,
+ meSats && styles.voted,
+ pending && styles.pending)}
+ style={style}
/>
diff --git a/components/upvote.module.css b/components/upvote.module.css
index a7126c888..014607691 100644
--- a/components/upvote.module.css
+++ b/components/upvote.module.css
@@ -13,8 +13,7 @@
}
.noSelfTips {
- fill: transparent !important;
- filter: none !important;
+ transform: scaleX(-1);
}
.upvoteWrapper:not(.noSelfTips):hover {
diff --git a/components/use-item-submit.js b/components/use-item-submit.js
index ac73a2077..23c3849b8 100644
--- a/components/use-item-submit.js
+++ b/components/use-item-submit.js
@@ -24,7 +24,7 @@ export default function useItemSubmit (mutation,
const { me } = useMe()
return useCallback(
- async ({ boost, crosspost, title, options, bounty, maxBid, start, stop, ...values }, { resetForm }) => {
+ async ({ boost, crosspost, title, options, bounty, status, ...values }, { resetForm }) => {
if (options) {
// remove existing poll options since else they will be appended as duplicates
options = options.slice(item?.poll?.options?.length || 0).filter(o => o.trim().length > 0)
@@ -46,8 +46,7 @@ export default function useItemSubmit (mutation,
sub: item?.subName || sub?.name,
boost: boost ? Number(boost) : undefined,
bounty: bounty ? Number(bounty) : undefined,
- maxBid: (maxBid || Number(maxBid) === 0) ? Number(maxBid) : undefined,
- status: start ? 'ACTIVE' : stop ? 'STOPPED' : undefined,
+ status: status === 'STOPPED' ? 'STOPPED' : 'ACTIVE',
title: title?.trim(),
options,
...values,
diff --git a/fragments/items.js b/fragments/items.js
index 34ccd0a0f..195693d78 100644
--- a/fragments/items.js
+++ b/fragments/items.js
@@ -46,15 +46,14 @@ export const ITEM_FIELDS = gql`
ncomments
commentSats
lastCommentAt
- maxBid
isJob
+ status
company
location
remote
subName
pollCost
pollExpiresAt
- status
uploadId
mine
imgproxyUrls
@@ -79,6 +78,7 @@ export const ITEM_FULL_FIELDS = gql`
bounty
bountyPaidTo
subName
+ mine
user {
id
name
diff --git a/fragments/paidAction.js b/fragments/paidAction.js
index 952d2f789..b1fc246f2 100644
--- a/fragments/paidAction.js
+++ b/fragments/paidAction.js
@@ -133,11 +133,11 @@ export const UPSERT_DISCUSSION = gql`
export const UPSERT_JOB = gql`
${PAID_ACTION}
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!,
- $location: String, $remote: Boolean, $text: String!, $url: String!, $maxBid: Int!,
+ $location: String, $remote: Boolean, $text: String!, $url: String!, $boost: Int,
$status: String, $logo: Int) {
upsertJob(sub: $sub, id: $id, title: $title, company: $company,
location: $location, remote: $remote, text: $text,
- url: $url, maxBid: $maxBid, status: $status, logo: $logo) {
+ url: $url, boost: $boost, status: $status, logo: $logo) {
result {
id
deleteScheduledAt
diff --git a/fragments/subs.js b/fragments/subs.js
index d055902f9..c9c4423e2 100644
--- a/fragments/subs.js
+++ b/fragments/subs.js
@@ -87,6 +87,9 @@ export const SUB_ITEMS = gql`
...CommentItemExtFields @include(if: $includeComments)
position
}
+ ad {
+ ...ItemFields
+ }
}
}
`
diff --git a/lib/apollo.js b/lib/apollo.js
index db0e6181c..53dd567a3 100644
--- a/lib/apollo.js
+++ b/lib/apollo.js
@@ -180,7 +180,8 @@ function getClient (uri) {
return {
cursor: incoming.cursor,
items: [...(existing?.items || []), ...incoming.items],
- pins: [...(existing?.pins || []), ...(incoming.pins || [])]
+ pins: [...(existing?.pins || []), ...(incoming.pins || [])],
+ ad: incoming?.ad || existing?.ad
}
}
},
diff --git a/lib/constants.js b/lib/constants.js
index 8a2435443..07ca03ac0 100644
--- a/lib/constants.js
+++ b/lib/constants.js
@@ -6,10 +6,10 @@ export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']
export const NOFOLLOW_LIMIT = 1000
export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener'
-export const BOOST_MULT = 5000
-export const BOOST_MIN = BOOST_MULT * 10
export const UPLOAD_SIZE_MAX = 50 * 1024 * 1024
export const UPLOAD_SIZE_MAX_AVATAR = 5 * 1024 * 1024
+export const BOOST_MULT = 10000
+export const BOOST_MIN = BOOST_MULT
export const IMAGE_PIXELS_MAX = 35000000
// backwards compatibile with old media domain env var and precedence for docker url if set
export const MEDIA_URL = process.env.MEDIA_URL_DOCKER || process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}`
@@ -25,7 +25,7 @@ export const UPLOAD_TYPES_ALLOW = [
'video/webm'
]
export const AVATAR_TYPES_ALLOW = UPLOAD_TYPES_ALLOW.filter(t => t.startsWith('image/'))
-export const INVOICE_ACTION_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'POLL_VOTE']
+export const INVOICE_ACTION_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'POLL_VOTE', 'BOOST']
export const BOUNTY_MIN = 1000
export const BOUNTY_MAX = 10000000
export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL']
@@ -91,16 +91,19 @@ export const TERRITORY_BILLING_OPTIONS = (labelPrefix) => ({
monthly: {
term: '+ 100k',
label: `${labelPrefix} month`,
+ op: '+',
modifier: cost => cost + TERRITORY_COST_MONTHLY
},
yearly: {
term: '+ 1m',
label: `${labelPrefix} year`,
+ op: '+',
modifier: cost => cost + TERRITORY_COST_YEARLY
},
once: {
term: '+ 3m',
label: 'one time',
+ op: '+',
modifier: cost => cost + TERRITORY_COST_ONCE
}
})
diff --git a/lib/format.js b/lib/format.js
index 2ac67e261..516f729aa 100644
--- a/lib/format.js
+++ b/lib/format.js
@@ -110,3 +110,18 @@ export const ensureB64 = hexOrB64Url => {
throw new Error('not a valid hex or base64 url or base64 encoded string')
}
+
+export function giveOrdinalSuffix (i) {
+ const j = i % 10
+ const k = i % 100
+ if (j === 1 && k !== 11) {
+ return i + 'st'
+ }
+ if (j === 2 && k !== 12) {
+ return i + 'nd'
+ }
+ if (j === 3 && k !== 13) {
+ return i + 'rd'
+ }
+ return i + 'th'
+}
diff --git a/lib/item.js b/lib/item.js
index f22f2c5f8..e44b53a3c 100644
--- a/lib/item.js
+++ b/lib/item.js
@@ -10,7 +10,7 @@ export const defaultCommentSort = (pinned, bio, createdAt) => {
return 'hot'
}
-export const isJob = item => item.maxBid !== null && typeof item.maxBid !== 'undefined'
+export const isJob = item => item.subName !== 'jobs'
// a delete directive preceded by a non word character that isn't a backtick
const deletePattern = /\B@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi
diff --git a/lib/validate.js b/lib/validate.js
index dda981106..a5d930b91 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -550,14 +550,13 @@ export const commentSchema = object({
text: textValidator(MAX_COMMENT_TEXT_LENGTH).required('required')
})
-export const jobSchema = object({
+export const jobSchema = args => object({
title: titleValidator,
company: string().required('required').trim(),
text: textValidator(MAX_POST_TEXT_LENGTH).required('required'),
url: string()
.or([string().email(), string().url()], 'invalid url or email')
.required('required'),
- maxBid: intValidator.min(0, 'must be at least 0').required('required'),
location: string().test(
'no-remote',
"don't write remote, just check the box",
@@ -565,7 +564,8 @@ export const jobSchema = object({
.when('remote', {
is: (value) => !value,
then: schema => schema.required('required').trim()
- })
+ }),
+ ...advPostSchemaMembers(args)
})
export const emailSchema = object({
@@ -585,9 +585,18 @@ export const amountSchema = object({
amount: intValidator.required('required').positive('must be positive')
})
+export const boostSchema = object({
+ amount: 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 actSchema = object({
sats: intValidator.required('required').positive('must be positive'),
- act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS'])
+ act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS', 'BOOST'])
})
export const settingsSchema = object().shape({
diff --git a/pages/items/[id]/edit.js b/pages/items/[id]/edit.js
index 07f4b1c7d..67572ca5d 100644
--- a/pages/items/[id]/edit.js
+++ b/pages/items/[id]/edit.js
@@ -49,6 +49,7 @@ export default function PostEdit ({ ssrData }) {
existingBoost: {
label: 'old boost',
term: `- ${item.boost}`,
+ op: '-',
modifier: cost => cost - item.boost
}
}
diff --git a/pages/rewards/index.js b/pages/rewards/index.js
index af9920fc5..89c5c7ed2 100644
--- a/pages/rewards/index.js
+++ b/pages/rewards/index.js
@@ -23,12 +23,15 @@ import { useMemo } from 'react'
import { CompactLongCountdown } from '@/components/countdown'
import { usePaidMutation } from '@/components/use-paid-mutation'
import { DONATE } from '@/fragments/paidAction'
+import { ITEM_FULL_FIELDS } from '@/fragments/items'
+import { ListItem } from '@/components/items'
const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), {
loading: () =>
})
const REWARDS_FULL = gql`
+${ITEM_FULL_FIELDS}
{
rewards {
total
@@ -37,6 +40,9 @@ const REWARDS_FULL = gql`
name
value
}
+ ad {
+ ...ItemFullFields
+ }
leaderboard {
users {
id
@@ -97,7 +103,7 @@ export default function Rewards ({ ssrData }) {
const { data } = useQuery(REWARDS_FULL)
const dat = useData(data, ssrData)
- let { rewards: [{ total, sources, time, leaderboard }] } = useMemo(() => {
+ let { rewards: [{ total, sources, time, leaderboard, ad }] } = useMemo(() => {
return dat || { rewards: [{}] }
}, [dat])
@@ -124,12 +130,13 @@ export default function Rewards ({ ssrData }) {
return (
-
- rewards are sponsored by ...
-
- SN is hiring
-
-
+ {ad &&
+
+
+ top boost this month
+
+
+
}
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);
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 65b4d0f21..66b2bb151 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -457,6 +457,7 @@ model Item {
company String?
weightedVotes Float @default(0)
boost Int @default(0)
+ oldBoost Int @default(0)
pollCost Int?
paidImgLink Boolean @default(false)
commentMsats BigInt @default(0)
@@ -804,6 +805,7 @@ enum InvoiceActionType {
ITEM_UPDATE
ZAP
DOWN_ZAP
+ BOOST
DONATE
POLL_VOTE
TERRITORY_CREATE
diff --git a/scripts/newsletter.js b/scripts/newsletter.js
index 6ddaf5894..f6bbb8d74 100644
--- a/scripts/newsletter.js
+++ b/scripts/newsletter.js
@@ -11,7 +11,6 @@ const ITEMS = gql`
ncomments
sats
company
- maxBid
status
location
remote
@@ -232,7 +231,7 @@ ${topCowboys.map((user, i) =>
------
##### Promoted jobs
-${jobs.data.items.items.filter(i => i.maxBid > 0 && i.status === 'ACTIVE').slice(0, 5).map((item, i) =>
+${jobs.data.items.items.filter(i => i.boost > 0).slice(0, 5).map((item, i) =>
`${i + 1}. [${item.title.trim()} \\ ${item.company} \\ ${item.location}${item.remote ? ' or Remote' : ''}](https://stacker.news/items/${item.id})\n`).join('')}
[**all jobs**](https://stacker.news/~jobs)
diff --git a/styles/globals.scss b/styles/globals.scss
index 175617178..6dc896889 100644
--- a/styles/globals.scss
+++ b/styles/globals.scss
@@ -245,6 +245,14 @@ $zindex-sticky: 900;
width: fit-content;
}
+.line-height-sm {
+ line-height: 1.25;
+}
+
+.line-height-md {
+ line-height: 1.5;
+}
+
@media (display-mode: standalone) {
.standalone {
display: flex
diff --git a/worker/auction.js b/worker/auction.js
deleted file mode 100644
index 602adbfa3..000000000
--- a/worker/auction.js
+++ /dev/null
@@ -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 })
- })
-}
diff --git a/worker/expireBoost.js b/worker/expireBoost.js
new file mode 100644
index 000000000..23b7f1550
--- /dev/null
+++ b/worker/expireBoost.js
@@ -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
+ }
+ )
+}
diff --git a/worker/index.js b/worker/index.js
index e2a5a795b..4c0132596 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -9,7 +9,6 @@ import {
} from './wallet.js'
import { repin } from './repin.js'
import { trust } from './trust.js'
-import { auction } from './auction.js'
import { earn } from './earn.js'
import apolloClient from '@apollo/client'
import { indexItem, indexAllItems } from './search.js'
@@ -35,6 +34,7 @@ import {
import { thisDay } from './thisDay.js'
import { isServiceEnabled } from '@/lib/sndev.js'
import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts.js'
+import { expireBoost } from './expireBoost.js'
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
@@ -117,12 +117,12 @@ async function work () {
await boss.work('imgproxy', jobWrapper(imgproxy))
await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages))
}
+ await boss.work('expireBoost', jobWrapper(expireBoost))
await boss.work('weeklyPost-*', jobWrapper(weeklyPost))
await boss.work('payWeeklyPostBounty', jobWrapper(payWeeklyPostBounty))
await boss.work('repin-*', jobWrapper(repin))
await boss.work('trust', jobWrapper(trust))
await boss.work('timestampItem', jobWrapper(timestampItem))
- await boss.work('auction', jobWrapper(auction))
await boss.work('earn', jobWrapper(earn))
await boss.work('streak', jobWrapper(computeStreaks))
await boss.work('checkStreak', jobWrapper(checkStreak))
diff --git a/worker/search.js b/worker/search.js
index 30a7fe4fc..d30ad6fd2 100644
--- a/worker/search.js
+++ b/worker/search.js
@@ -22,7 +22,6 @@ const ITEM_SEARCH_FIELDS = gql`
subName
}
status
- maxBid
company
location
remote