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 ( +
    +
  1. Boost ranks items higher based on the amount
  2. +
  3. The highest boost in a territory over the last 30 days is pinned to the top of the territory
  4. +
  5. The highest boost across all territories over the last 30 days is pinned to the top of the homepage
  6. +
  7. The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}
  8. +
  9. Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker + +
  10. +
  11. The decay of boost "votes" increases at 1.25x the rate of organic votes + +
  12. +
  13. boost can take a few minutes to show higher ranking in feed
  14. +
  15. 100% of boost goes to the territory founder and top stackers as rewards
  16. +
+ ) +} + +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 - -
    -
  1. Boost ranks posts higher temporarily based on the amount
  2. -
  3. The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}
  4. -
  5. 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
    • -
    -
  6. -
  7. The decay of boost "votes" increases at 1.25x the rate of organic votes -
      -
    • i.e. boost votes fall out of ranking faster
    • -
    -
  8. -
  9. 100% of sats from boost are given back to top stackers as rewards
  10. -
-
- - } - 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 && 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 ( + + + {children} +
+ + boost + +
+ + ) +} + +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 ( -
- sats} - /> -
- -
- {children} -
- {down && 'down'}zap -
- - ) + return act === 'BOOST' + ? {children} + : ( +
+ sats} + /> + +
+ +
+ {children} +
+ + {act === 'DONT_LIKE_THIS' ? 'downzap' : 'zap'} + +
+ ) } 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 + +
    +
  1. Boost ranks jobs higher based on the amount
  2. +
  3. The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}
  4. +
  5. Boost must be divisible by {numWithUnits(BOOST_MULT, { abbreviate: false })}
  6. +
  7. 100% of boost goes to the territory founder and top stackers as rewards
  8. +
+
+
+ } + 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 - -
    -
  1. The higher your bid the higher your job will rank
  2. -
  3. You can increase, decrease, or remove your bid at anytime
  4. -
  5. You can edit or stop your job at anytime
  6. -
  7. If you run out of sats, your job will stop being promoted until you fill your wallet again
  8. -
-
- 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
{term}