Compare commits
8 Commits
ae579cbec3
...
3310925155
Author | SHA1 | Date |
---|---|---|
Keyan | 3310925155 | |
k00b | 020b4c5eea | |
k00b | e323ed27c6 | |
k00b | d17929f2c5 | |
Keyan | 5f0494de30 | |
Keyan | beba2f4794 | |
ekzyis | dcbe83f155 | |
ekzyis | 5088673b84 |
|
@ -0,0 +1,77 @@
|
|||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||
|
||||
export const anonable = false
|
||||
export const supportsPessimism = false
|
||||
export const supportsOptimism = true
|
||||
|
||||
export async function getCost ({ sats }) {
|
||||
return satsToMsats(sats)
|
||||
}
|
||||
|
||||
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) {
|
||||
itemId = parseInt(itemId)
|
||||
|
||||
let invoiceData = {}
|
||||
if (invoiceId) {
|
||||
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
|
||||
// store a reference to the item in the invoice
|
||||
await tx.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: { actionId: itemId }
|
||||
})
|
||||
}
|
||||
|
||||
const act = await tx.itemAct.create({ data: { msats: cost, itemId, userId: me.id, act: 'BOOST', ...invoiceData } })
|
||||
|
||||
const [{ path }] = await tx.$queryRaw`
|
||||
SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
|
||||
return { id: itemId, sats, act: 'BOOST', path, actId: act.id }
|
||||
}
|
||||
|
||||
export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
|
||||
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
|
||||
const [{ id, path }] = await tx.$queryRaw`
|
||||
SELECT "Item".id, ltree2text(path) as path
|
||||
FROM "Item"
|
||||
JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId"
|
||||
WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER`
|
||||
return { id, sats: msatsToSats(cost), act: 'BOOST', path }
|
||||
}
|
||||
|
||||
export async function onPaid ({ invoice, actId }, { models, tx }) {
|
||||
let itemAct
|
||||
if (invoice) {
|
||||
await tx.itemAct.updateMany({
|
||||
where: { invoiceId: invoice.id },
|
||||
data: {
|
||||
invoiceActionState: 'PAID'
|
||||
}
|
||||
})
|
||||
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id } })
|
||||
} else if (actId) {
|
||||
itemAct = await tx.itemAct.findFirst({ where: { id: actId } })
|
||||
} else {
|
||||
throw new Error('No invoice or actId')
|
||||
}
|
||||
|
||||
// increment boost on item
|
||||
await tx.item.update({
|
||||
where: { id: itemAct.itemId },
|
||||
data: {
|
||||
boost: { increment: msatsToSats(itemAct.msats) }
|
||||
}
|
||||
})
|
||||
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
|
||||
VALUES ('expireBoost', jsonb_build_object('id', ${itemAct.itemId}::INTEGER), 21, true,
|
||||
now() + interval '30 days', interval '40 days')`
|
||||
}
|
||||
|
||||
export async function onFail ({ invoice }, { tx }) {
|
||||
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
|
||||
}
|
||||
|
||||
export async function describe ({ id: itemId, sats }, { actionId, cost }) {
|
||||
return `SN: boost ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
|
||||
}
|
|
@ -13,6 +13,7 @@ import * as TERRITORY_UPDATE from './territoryUpdate'
|
|||
import * as TERRITORY_BILLING from './territoryBilling'
|
||||
import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
|
||||
import * as DONATE from './donate'
|
||||
import * as BOOST from './boost'
|
||||
import wrapInvoice from 'wallets/wrap'
|
||||
import { createInvoice as createUserInvoice } from 'wallets/server'
|
||||
|
||||
|
@ -21,6 +22,7 @@ export const paidActions = {
|
|||
ITEM_UPDATE,
|
||||
ZAP,
|
||||
DOWN_ZAP,
|
||||
BOOST,
|
||||
POLL_VOTE,
|
||||
TERRITORY_CREATE,
|
||||
TERRITORY_UPDATE,
|
||||
|
@ -186,7 +188,12 @@ export async function retryPaidAction (actionType, args, context) {
|
|||
context.optimistic = true
|
||||
context.me = await models.user.findUnique({ where: { id: me.id } })
|
||||
|
||||
const { msatsRequested, actionId } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
|
||||
const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
|
||||
if (!failedInvoice) {
|
||||
throw new Error(`retryPaidAction - invoice not found or not in failed state ${actionType}`)
|
||||
}
|
||||
|
||||
const { msatsRequested, actionId } = failedInvoice
|
||||
context.cost = BigInt(msatsRequested)
|
||||
context.actionId = actionId
|
||||
const invoiceArgs = await createSNInvoice(actionType, args, context)
|
||||
|
@ -245,8 +252,8 @@ export async function createLightningInvoice (actionType, args, context) {
|
|||
try {
|
||||
const description = await paidActions[actionType].describe(args, context)
|
||||
const { invoice: bolt11, wallet } = await createUserInvoice(userId, {
|
||||
// this is the amount the stacker will receive, the other 1/10th is the fee
|
||||
msats: cost * BigInt(9) / BigInt(10),
|
||||
// this is the amount the stacker will receive, the other 3/10ths is the sybil fee
|
||||
msats: cost * BigInt(7) / BigInt(10),
|
||||
description,
|
||||
expiry: INVOICE_EXPIRE_SECS
|
||||
}, { models })
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ export async function invoiceablePeer ({ id }, { models }) {
|
|||
}
|
||||
|
||||
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) {
|
||||
const feeMsats = cost / BigInt(10) // 10% fee
|
||||
const feeMsats = 3n * (cost / BigInt(10)) // 30% fee
|
||||
const zapMsats = cost - feeMsats
|
||||
itemId = parseInt(itemId)
|
||||
|
||||
|
|
|
@ -6,8 +6,9 @@ import domino from 'domino'
|
|||
import {
|
||||
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
|
||||
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
|
||||
USER_ID, POLL_COST,
|
||||
ADMIN_ITEMS, GLOBAL_SEED, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS
|
||||
USER_ID, POLL_COST, ADMIN_ITEMS, GLOBAL_SEED,
|
||||
NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS,
|
||||
BOOST_MULT
|
||||
} from '@/lib/constants'
|
||||
import { msatsToSats } from '@/lib/format'
|
||||
import { parse } from 'tldts'
|
||||
|
@ -30,13 +31,13 @@ function commentsOrderByClause (me, models, sort) {
|
|||
if (me && sort === 'hot') {
|
||||
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, COALESCE(
|
||||
personal_hot_score,
|
||||
${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST,
|
||||
${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST,
|
||||
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
} else {
|
||||
if (sort === 'top') {
|
||||
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator(models, 0)} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0 })} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
} else {
|
||||
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -73,6 +74,29 @@ export async function getItem (parent, { id }, { me, models }) {
|
|||
return item
|
||||
}
|
||||
|
||||
export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { me, models }) {
|
||||
return (await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
|
||||
${whereClause(
|
||||
'"parentId" IS NULL',
|
||||
'"Item"."pinId" IS NULL',
|
||||
'"Item"."deletedAt" IS NULL',
|
||||
'"Item"."parentId" IS NULL',
|
||||
'"Item".bio = false',
|
||||
'"Item".boost > 0',
|
||||
activeOrMine(),
|
||||
subClause(sub, 1, 'Item', me, showNsfw),
|
||||
muteClause(me))}
|
||||
ORDER BY boost desc, "Item".created_at ASC
|
||||
LIMIT 1`
|
||||
}, ...subArr))?.[0] || null
|
||||
}
|
||||
|
||||
const orderByClause = (by, me, models, type) => {
|
||||
switch (by) {
|
||||
case 'comments':
|
||||
|
@ -88,12 +112,12 @@ const orderByClause = (by, me, models, type) => {
|
|||
}
|
||||
}
|
||||
|
||||
export function orderByNumerator (models, commentScaler = 0.5) {
|
||||
export function orderByNumerator ({ models, commentScaler = 0.5, considerBoost = false }) {
|
||||
return `(CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0 THEN
|
||||
GREATEST("Item"."weightedVotes" - "Item"."weightedDownVotes", POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", 1.2))
|
||||
ELSE
|
||||
"Item"."weightedVotes" - "Item"."weightedDownVotes"
|
||||
END + "Item"."weightedComments"*${commentScaler})`
|
||||
END + "Item"."weightedComments"*${commentScaler}) + ${considerBoost ? `("Item".boost / ${BOOST_MULT})` : 0}`
|
||||
}
|
||||
|
||||
export function joinZapRankPersonalView (me, models) {
|
||||
|
@ -304,7 +328,7 @@ export default {
|
|||
},
|
||||
items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit = LIMIT }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
let items, user, pins, subFull, table
|
||||
let items, user, pins, subFull, table, ad
|
||||
|
||||
// special authorization for bookmarks depending on owning users' privacy settings
|
||||
if (type === 'bookmarks' && name && me?.name !== name) {
|
||||
|
@ -442,27 +466,54 @@ export default {
|
|||
models,
|
||||
query: `
|
||||
${SELECT},
|
||||
CASE WHEN status = 'ACTIVE' AND "maxBid" > 0
|
||||
THEN 0 ELSE 1 END AS group_rank,
|
||||
CASE WHEN status = 'ACTIVE' AND "maxBid" > 0
|
||||
THEN rank() OVER (ORDER BY "maxBid" DESC, created_at ASC)
|
||||
(boost IS NOT NULL AND boost > 0)::INT AS group_rank,
|
||||
CASE WHEN boost IS NOT NULL AND boost > 0
|
||||
THEN rank() OVER (ORDER BY boost DESC, created_at ASC)
|
||||
ELSE rank() OVER (ORDER BY created_at DESC) END AS rank
|
||||
FROM "Item"
|
||||
${whereClause(
|
||||
'"parentId" IS NULL',
|
||||
'"Item"."deletedAt" IS NULL',
|
||||
'"Item"."status" = \'ACTIVE\'',
|
||||
'created_at <= $1',
|
||||
'"pinId" IS NULL',
|
||||
subClause(sub, 4),
|
||||
"status IN ('ACTIVE', 'NOSATS')"
|
||||
subClause(sub, 4)
|
||||
)}
|
||||
ORDER BY group_rank, rank
|
||||
OFFSET $2
|
||||
LIMIT $3`,
|
||||
orderBy: 'ORDER BY group_rank, rank'
|
||||
orderBy: 'ORDER BY group_rank DESC, rank'
|
||||
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
||||
break
|
||||
default:
|
||||
if (decodedCursor.offset === 0) {
|
||||
// get pins for the page and return those separately
|
||||
pins = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
SELECT rank_filter.*
|
||||
FROM (
|
||||
${SELECT}, position,
|
||||
rank() OVER (
|
||||
PARTITION BY "pinId"
|
||||
ORDER BY "Item".created_at DESC
|
||||
)
|
||||
FROM "Item"
|
||||
JOIN "Pin" ON "Item"."pinId" = "Pin".id
|
||||
${whereClause(
|
||||
'"pinId" IS NOT NULL',
|
||||
'"parentId" IS NULL',
|
||||
sub ? '"subName" = $1' : '"subName" IS NULL',
|
||||
muteClause(me))}
|
||||
) rank_filter WHERE RANK = 1
|
||||
ORDER BY position ASC`,
|
||||
orderBy: 'ORDER BY position ASC'
|
||||
}, ...subArr)
|
||||
|
||||
ad = await getAd(parent, { sub, subArr, showNsfw }, { me, models })
|
||||
}
|
||||
|
||||
items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
|
@ -478,6 +529,7 @@ export default {
|
|||
'"Item"."parentId" IS NULL',
|
||||
'"Item".outlawed = false',
|
||||
'"Item".bio = false',
|
||||
ad ? `"Item".id <> ${ad.id}` : '',
|
||||
activeOrMine(me),
|
||||
subClause(sub, 3, 'Item', me, showNsfw),
|
||||
muteClause(me))}
|
||||
|
@ -487,8 +539,8 @@ export default {
|
|||
orderBy: 'ORDER BY rank DESC'
|
||||
}, decodedCursor.offset, limit, ...subArr)
|
||||
|
||||
// XXX this is just for subs that are really empty
|
||||
if (decodedCursor.offset === 0 && items.length < limit) {
|
||||
// XXX this is mostly for subs that are really empty
|
||||
if (items.length < limit) {
|
||||
items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
|
@ -504,40 +556,17 @@ export default {
|
|||
'"Item"."deletedAt" IS NULL',
|
||||
'"Item"."parentId" IS NULL',
|
||||
'"Item".bio = false',
|
||||
ad ? `"Item".id <> ${ad.id}` : '',
|
||||
activeOrMine(me),
|
||||
await filterClause(me, models, type))}
|
||||
ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC
|
||||
ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
|
||||
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC
|
||||
OFFSET $1
|
||||
LIMIT $2`,
|
||||
orderBy: `ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
orderBy: `ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
|
||||
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||
}, decodedCursor.offset, limit, ...subArr)
|
||||
}
|
||||
|
||||
if (decodedCursor.offset === 0) {
|
||||
// get pins for the page and return those separately
|
||||
pins = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
SELECT rank_filter.*
|
||||
FROM (
|
||||
${SELECT}, position,
|
||||
rank() OVER (
|
||||
PARTITION BY "pinId"
|
||||
ORDER BY "Item".created_at DESC
|
||||
)
|
||||
FROM "Item"
|
||||
JOIN "Pin" ON "Item"."pinId" = "Pin".id
|
||||
${whereClause(
|
||||
'"pinId" IS NOT NULL',
|
||||
'"parentId" IS NULL',
|
||||
sub ? '"subName" = $1' : '"subName" IS NULL',
|
||||
muteClause(me))}
|
||||
) rank_filter WHERE RANK = 1
|
||||
ORDER BY position ASC`,
|
||||
orderBy: 'ORDER BY position ASC'
|
||||
}, ...subArr)
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
|
@ -545,7 +574,8 @@ export default {
|
|||
return {
|
||||
cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
|
||||
items,
|
||||
pins
|
||||
pins,
|
||||
ad
|
||||
}
|
||||
},
|
||||
item: getItem,
|
||||
|
@ -615,18 +645,17 @@ export default {
|
|||
LIMIT 3`
|
||||
}, similar)
|
||||
},
|
||||
auctionPosition: async (parent, { id, sub, bid }, { models, me }) => {
|
||||
auctionPosition: async (parent, { id, sub, boost }, { models, me }) => {
|
||||
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
|
||||
let where
|
||||
if (bid > 0) {
|
||||
// if there's a bid
|
||||
// it's ACTIVE and has a larger bid than ours, or has an equal bid and is older
|
||||
// count items: (bid > ours.bid OR (bid = ours.bid AND create_at < ours.created_at)) AND status = 'ACTIVE'
|
||||
if (boost > 0) {
|
||||
// if there's boost
|
||||
// has a larger boost than ours, or has an equal boost and is older
|
||||
// count items: (boost > ours.boost OR (boost = ours.boost AND create_at < ours.created_at))
|
||||
where = {
|
||||
status: 'ACTIVE',
|
||||
OR: [
|
||||
{ maxBid: { gt: bid } },
|
||||
{ maxBid: bid, createdAt: { lt: createdAt } }
|
||||
{ boost: { gt: boost } },
|
||||
{ boost, createdAt: { lt: createdAt } }
|
||||
]
|
||||
}
|
||||
} else {
|
||||
|
@ -635,18 +664,42 @@ export default {
|
|||
// count items: ((bid > ours.bid AND status = 'ACTIVE') OR (created_at > ours.created_at AND status <> 'STOPPED'))
|
||||
where = {
|
||||
OR: [
|
||||
{ maxBid: { gt: 0 }, status: 'ACTIVE' },
|
||||
{ createdAt: { gt: createdAt }, status: { not: 'STOPPED' } }
|
||||
{ boost: { gt: 0 } },
|
||||
{ createdAt: { gt: createdAt } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
where.subName = sub
|
||||
where.AND = {
|
||||
subName: sub,
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null
|
||||
}
|
||||
if (id) {
|
||||
where.AND.id = { not: Number(id) }
|
||||
}
|
||||
|
||||
return await models.item.count({ where }) + 1
|
||||
},
|
||||
boostPosition: async (parent, { id, sub, boost }, { models, me }) => {
|
||||
if (boost <= 0) {
|
||||
throw new GqlInputError('boost must be greater than 0')
|
||||
}
|
||||
|
||||
const where = {
|
||||
boost: { gte: boost },
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
outlawed: false
|
||||
}
|
||||
if (id) {
|
||||
where.id = { not: Number(id) }
|
||||
}
|
||||
|
||||
return await models.item.count({ where }) + 1
|
||||
return {
|
||||
home: await models.item.count({ where }) === 0,
|
||||
sub: await models.item.count({ where: { ...where, subName: sub } }) === 0
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -825,7 +878,6 @@ export default {
|
|||
item.uploadId = item.logo
|
||||
delete item.logo
|
||||
}
|
||||
item.maxBid ??= 0
|
||||
|
||||
if (id) {
|
||||
return await updateItem(parent, { id, ...item }, { me, models, lnd })
|
||||
|
@ -862,7 +914,7 @@ export default {
|
|||
|
||||
return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd })
|
||||
},
|
||||
act: async (parent, { id, sats, act = 'TIP', idempotent }, { me, models, lnd, headers }) => {
|
||||
act: async (parent, { id, sats, act = 'TIP' }, { me, models, lnd, headers }) => {
|
||||
assertApiKeyNotPermitted({ me })
|
||||
await ssValidate(actSchema, { sats, act })
|
||||
await assertGofacYourself({ models, headers })
|
||||
|
@ -881,7 +933,7 @@ export default {
|
|||
}
|
||||
|
||||
// disallow self tips except anons
|
||||
if (me) {
|
||||
if (me && ['TIP', 'DONT_LIKE_THIS'].includes(act)) {
|
||||
if (Number(item.userId) === Number(me.id)) {
|
||||
throw new GqlInputError('cannot zap yourself')
|
||||
}
|
||||
|
@ -899,6 +951,8 @@ export default {
|
|||
return await performPaidAction('ZAP', { id, sats }, { me, models, lnd })
|
||||
} else if (act === 'DONT_LIKE_THIS') {
|
||||
return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd })
|
||||
} else if (act === 'BOOST') {
|
||||
return await performPaidAction('BOOST', { id, sats }, { me, models, lnd })
|
||||
} else {
|
||||
throw new GqlInputError('unknown act')
|
||||
}
|
||||
|
@ -1321,7 +1375,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
|
|||
item = { id: Number(item.id), text: item.text, title: `@${user.name}'s bio` }
|
||||
} else if (old.parentId) {
|
||||
// prevent editing a comment like a post
|
||||
item = { id: Number(item.id), text: item.text }
|
||||
item = { id: Number(item.id), text: item.text, boost: item.boost }
|
||||
} else {
|
||||
item = { subName, ...item }
|
||||
item.forwardUsers = await getForwardUsers(models, forward)
|
||||
|
@ -1390,5 +1444,5 @@ export const SELECT =
|
|||
ltree2text("Item"."path") AS "path"`
|
||||
|
||||
function topOrderByWeightedSats (me, models) {
|
||||
return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC`
|
||||
return `ORDER BY ${orderByNumerator({ models })} DESC NULLS LAST, "Item".id DESC`
|
||||
}
|
||||
|
|
|
@ -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})`
|
||||
|
|
|
@ -8,6 +8,7 @@ function paidActionType (actionType) {
|
|||
return 'ItemPaidAction'
|
||||
case 'ZAP':
|
||||
case 'DOWN_ZAP':
|
||||
case 'BOOST':
|
||||
return 'ItemActPaidAction'
|
||||
case 'TERRITORY_CREATE':
|
||||
case 'TERRITORY_UPDATE':
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -8,10 +8,16 @@ export default gql`
|
|||
dupes(url: String!): [Item!]
|
||||
related(cursor: String, title: String, id: ID, minMatch: String, limit: Limit): Items
|
||||
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String, from: String, to: String): Items
|
||||
auctionPosition(sub: String, id: ID, bid: Int!): Int!
|
||||
auctionPosition(sub: String, id: ID, boost: Int): Int!
|
||||
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
|
||||
itemRepetition(parentId: ID): Int!
|
||||
}
|
||||
|
||||
type BoostPositions {
|
||||
home: Boolean!
|
||||
sub: Boolean!
|
||||
}
|
||||
|
||||
type TitleUnshorted {
|
||||
title: String
|
||||
unshorted: String
|
||||
|
@ -46,12 +52,12 @@ export default gql`
|
|||
hash: String, hmac: String): ItemPaidAction!
|
||||
upsertJob(
|
||||
id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
|
||||
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): ItemPaidAction!
|
||||
text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction!
|
||||
upsertPoll(
|
||||
id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date,
|
||||
hash: String, hmac: String): ItemPaidAction!
|
||||
updateNoteId(id: ID!, noteId: String!): Item!
|
||||
upsertComment(id: ID, text: String!, parentId: ID, hash: String, hmac: String): ItemPaidAction!
|
||||
upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction!
|
||||
act(id: ID!, sats: Int, act: String, idempotent: Boolean): ItemActPaidAction!
|
||||
pollVote(id: ID!): PollVotePaidAction!
|
||||
toggleOutlaw(id: ID!): Item!
|
||||
|
@ -79,6 +85,7 @@ export default gql`
|
|||
cursor: String
|
||||
items: [Item!]!
|
||||
pins: [Item!]
|
||||
ad: Item
|
||||
}
|
||||
|
||||
type Comments {
|
||||
|
@ -136,7 +143,6 @@ export default gql`
|
|||
path: String
|
||||
position: Int
|
||||
prior: Int
|
||||
maxBid: Int
|
||||
isJob: Boolean!
|
||||
pollCost: Int
|
||||
poll: Poll
|
||||
|
|
|
@ -19,6 +19,7 @@ export default gql`
|
|||
time: Date!
|
||||
sources: [NameValue!]!
|
||||
leaderboard: UsersNullable
|
||||
ad: Item
|
||||
}
|
||||
|
||||
type Reward {
|
||||
|
|
|
@ -127,3 +127,4 @@ brugeman,issue,#1311,#864,medium,high,,,50k,brugeman@stacker.news,2024-08-27
|
|||
riccardobl,pr,#1342,#1141,hard,high,,pending unrelated rearchitecture,1m,rblb@getalby.com,2024-09-09
|
||||
SatsAllDay,issue,#1368,#1331,medium,,,,25k,weareallsatoshi@getalby.com,2024-09-16
|
||||
benalleng,helpfulness,#1368,#1170,medium,,,did a lot of it in #1175,25k,BenAllenG@stacker.news,2024-09-16
|
||||
humble-GOAT,issue,#1412,#1407,good-first-issue,,,,2k,humble_GOAT@stacker.news,2024-09-18
|
||||
|
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import AccordianItem from './accordian-item'
|
||||
import { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
|
||||
import InputGroup from 'react-bootstrap/InputGroup'
|
||||
|
@ -11,6 +11,8 @@ import { useMe } from './me'
|
|||
import { useFeeButton } from './fee-button'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useFormikContext } from 'formik'
|
||||
import { gql, useLazyQuery } from '@apollo/client'
|
||||
import useDebounceCallback from './use-debounce-callback'
|
||||
|
||||
const EMPTY_FORWARD = { nym: '', pct: '' }
|
||||
|
||||
|
@ -26,9 +28,118 @@ const FormStatus = {
|
|||
ERROR: 'error'
|
||||
}
|
||||
|
||||
export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
|
||||
export function BoostHelp () {
|
||||
return (
|
||||
<ol style={{ lineHeight: 1.25 }}>
|
||||
<li>Boost ranks items higher based on the amount</li>
|
||||
<li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li>
|
||||
<li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li>
|
||||
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
|
||||
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker
|
||||
<ul>
|
||||
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>The decay of boost "votes" increases at 1.25x the rate of organic votes
|
||||
<ul>
|
||||
<li>i.e. boost votes fall out of ranking faster</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>boost can take a few minutes to show higher ranking in feed</li>
|
||||
<li>100% of boost goes to the territory founder and top stackers as rewards</li>
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
export function BoostInput ({ onChange, ...props }) {
|
||||
const feeButton = useFeeButton()
|
||||
let merge
|
||||
if (feeButton) {
|
||||
({ merge } = feeButton)
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
label={
|
||||
<div className='d-flex align-items-center'>boost
|
||||
<Info>
|
||||
<BoostHelp />
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='boost'
|
||||
onChange={(_, e) => {
|
||||
merge?.({
|
||||
boost: {
|
||||
term: `+ ${e.target.value}`,
|
||||
label: 'boost',
|
||||
op: '+',
|
||||
modifier: cost => cost + Number(e.target.value)
|
||||
}
|
||||
})
|
||||
onChange && onChange(_, e)
|
||||
}}
|
||||
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// act means we are adding to existing boost
|
||||
export function BoostItemInput ({ item, sub, act = false, ...props }) {
|
||||
const [boost, setBoost] = useState(Number(item?.boost) + (act ? BOOST_MULT : 0))
|
||||
|
||||
const [getBoostPosition, { data }] = useLazyQuery(gql`
|
||||
query BoostPosition($id: ID, $boost: Int) {
|
||||
boostPosition(sub: "${item?.subName || sub?.name}", id: $id, boost: $boost) {
|
||||
home
|
||||
sub
|
||||
}
|
||||
}`,
|
||||
{ fetchPolicy: 'cache-and-network' })
|
||||
|
||||
const getPositionDebounce = useDebounceCallback((...args) => getBoostPosition(...args), 1000, [getBoostPosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (boost) {
|
||||
getPositionDebounce({ variables: { boost: Number(boost), id: item?.id } })
|
||||
}
|
||||
}, [boost, item?.id])
|
||||
|
||||
const boostMessage = useMemo(() => {
|
||||
if (!item?.parentId) {
|
||||
if (data?.boostPosition?.home || data?.boostPosition?.sub) {
|
||||
const boostPinning = []
|
||||
if (data?.boostPosition?.home) {
|
||||
boostPinning.push('homepage')
|
||||
}
|
||||
if (data?.boostPosition?.sub) {
|
||||
boostPinning.push(`~${item?.subName || sub?.name}`)
|
||||
}
|
||||
return `pins to the top of ${boostPinning.join(' and ')}`
|
||||
}
|
||||
}
|
||||
if (boost >= 0 && boost % BOOST_MULT === 0) {
|
||||
return `${act ? 'brings to' : 'equivalent to'} ${numWithUnits(boost / BOOST_MULT, { unitPlural: 'zapvotes', unitSingular: 'zapvote' })}`
|
||||
}
|
||||
return 'ranks posts higher based on the amount'
|
||||
}, [boost, data?.boostPosition?.home, data?.boostPosition?.sub, item?.subName, sub?.name])
|
||||
|
||||
return (
|
||||
<BoostInput
|
||||
hint={<span className='text-muted'>{boostMessage}</span>}
|
||||
onChange={(_, e) => {
|
||||
if (e.target.value >= 0) {
|
||||
setBoost(Number(e.target.value) + (act ? Number(item?.boost) : 0))
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdvPostForm ({ children, item, sub, storageKeyPrefix }) {
|
||||
const { me } = useMe()
|
||||
const { merge } = useFeeButton()
|
||||
const router = useRouter()
|
||||
const [itemType, setItemType] = useState()
|
||||
const formik = useFormikContext()
|
||||
|
@ -111,39 +222,7 @@ export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
|
|||
body={
|
||||
<>
|
||||
{children}
|
||||
<Input
|
||||
label={
|
||||
<div className='d-flex align-items-center'>boost
|
||||
<Info>
|
||||
<ol>
|
||||
<li>Boost ranks posts higher temporarily based on the amount</li>
|
||||
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
|
||||
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to one trusted upvote
|
||||
<ul>
|
||||
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like 5 votes</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>The decay of boost "votes" increases at 1.25x the rate of organic votes
|
||||
<ul>
|
||||
<li>i.e. boost votes fall out of ranking faster</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>100% of sats from boost are given back to top stackers as rewards</li>
|
||||
</ol>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='boost'
|
||||
onChange={(_, e) => merge({
|
||||
boost: {
|
||||
term: `+ ${e.target.value}`,
|
||||
label: 'boost',
|
||||
modifier: cost => cost + Number(e.target.value)
|
||||
}
|
||||
})}
|
||||
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<BoostItemInput item={item} sub={sub} />
|
||||
<VariableInput
|
||||
label='forward sats to'
|
||||
name='forward'
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import { useShowModal } from './modal'
|
||||
import { useToast } from './toast'
|
||||
import ItemAct from './item-act'
|
||||
import AccordianItem from './accordian-item'
|
||||
import { useMemo } from 'react'
|
||||
import getColor from '@/lib/rainbow'
|
||||
import UpBolt from '@/svgs/bolt.svg'
|
||||
import styles from './upvote.module.css'
|
||||
import { BoostHelp } from './adv-post-form'
|
||||
import { BOOST_MULT } from '@/lib/constants'
|
||||
import classNames from 'classnames'
|
||||
|
||||
export default function Boost ({ item, className, ...props }) {
|
||||
const { boost } = item
|
||||
const style = useMemo(() => (boost
|
||||
? {
|
||||
fill: getColor(boost),
|
||||
filter: `drop-shadow(0 0 6px ${getColor(boost)}90)`,
|
||||
transform: 'scaleX(-1)'
|
||||
}
|
||||
: {
|
||||
transform: 'scaleX(-1)'
|
||||
}), [boost])
|
||||
return (
|
||||
<Booster
|
||||
item={item} As={({ ...oprops }) =>
|
||||
<div className='upvoteParent'>
|
||||
<div
|
||||
className={styles.upvoteWrapper}
|
||||
>
|
||||
<UpBolt
|
||||
{...props} {...oprops} style={style}
|
||||
width={26}
|
||||
height={26}
|
||||
className={classNames(styles.upvote, className, boost && styles.voted)}
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Booster ({ item, As, children }) {
|
||||
const toaster = useToast()
|
||||
const showModal = useShowModal()
|
||||
|
||||
return (
|
||||
<As
|
||||
onClick={async () => {
|
||||
try {
|
||||
showModal(onClose =>
|
||||
<ItemAct onClose={onClose} item={item} act='BOOST' step={BOOST_MULT}>
|
||||
<AccordianItem header='what is boost?' body={<BoostHelp />} />
|
||||
</ItemAct>)
|
||||
} catch (error) {
|
||||
toaster.danger('failed to boost item')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</As>
|
||||
)
|
||||
}
|
|
@ -80,7 +80,7 @@ export function BountyForm ({
|
|||
: null
|
||||
}
|
||||
/>
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} />
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub} />
|
||||
<ItemButtonBar itemId={item?.id} canDelete={false} />
|
||||
</Form>
|
||||
)
|
||||
|
|
|
@ -25,6 +25,7 @@ import Skull from '@/svgs/death-skull.svg'
|
|||
import { commentSubTreeRootId } from '@/lib/item'
|
||||
import Pin from '@/svgs/pushpin-fill.svg'
|
||||
import LinkToContext from './link-to-context'
|
||||
import Boost from './boost-button'
|
||||
|
||||
function Parent ({ item, rootText }) {
|
||||
const root = useRoot()
|
||||
|
@ -144,9 +145,11 @@ export default function Comment ({
|
|||
<div className={`${itemStyles.item} ${styles.item}`}>
|
||||
{item.outlawed && !me?.privates?.wildWestMode
|
||||
? <Skull className={styles.dontLike} width={24} height={24} />
|
||||
: item.meDontLikeSats > item.meSats
|
||||
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
|
||||
: item.mine
|
||||
? <Boost item={item} className={styles.upvote} />
|
||||
: item.meDontLikeSats > item.meSats
|
||||
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
|
||||
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
||||
<div className='d-flex align-items-center'>
|
||||
{item.user?.meMute && !includeParent && collapse === 'yep'
|
||||
|
|
|
@ -79,7 +79,7 @@ export function DiscussionForm ({
|
|||
? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div>
|
||||
: null}
|
||||
/>
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} />
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub} />
|
||||
<ItemButtonBar itemId={item?.id} />
|
||||
{!item &&
|
||||
<div className={`mt-3 ${related.length > 0 ? '' : 'invisible'}`}>
|
||||
|
|
|
@ -17,7 +17,12 @@ export function DownZap ({ item, ...props }) {
|
|||
}
|
||||
: undefined), [meDontLikeSats])
|
||||
return (
|
||||
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
|
||||
<DownZapper
|
||||
item={item} As={({ ...oprops }) =>
|
||||
<div className='upvoteParent'>
|
||||
<Flag {...props} {...oprops} style={style} />
|
||||
</div>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -31,7 +36,7 @@ function DownZapper ({ item, As, children }) {
|
|||
try {
|
||||
showModal(onClose =>
|
||||
<ItemAct
|
||||
onClose={onClose} item={item} down
|
||||
onClose={onClose} item={item} act='DONT_LIKE_THIS'
|
||||
>
|
||||
<AccordianItem
|
||||
header='what is a downzap?' body={
|
||||
|
|
|
@ -21,6 +21,7 @@ export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me })
|
|||
anonCharge: {
|
||||
term: `x ${ANON_FEE_MULTIPLIER}`,
|
||||
label: 'anon mult',
|
||||
op: '*',
|
||||
modifier: (cost) => cost * ANON_FEE_MULTIPLIER
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +29,7 @@ export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me })
|
|||
baseCost: {
|
||||
term: baseCost,
|
||||
label: `${comment ? 'comment' : 'post'} cost`,
|
||||
op: '_',
|
||||
modifier: (cost) => cost + baseCost,
|
||||
allowFreebies: comment
|
||||
},
|
||||
|
@ -48,10 +50,12 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
|
|||
useEffect(() => {
|
||||
const repetition = data?.itemRepetition
|
||||
if (!repetition) return setLine({})
|
||||
console.log('repetition', repetition)
|
||||
setLine({
|
||||
itemRepetition: {
|
||||
term: <>x 10<sup>{repetition}</sup></>,
|
||||
label: <>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</>,
|
||||
op: '*',
|
||||
modifier: (cost) => cost * Math.pow(10, repetition)
|
||||
}
|
||||
})
|
||||
|
@ -61,6 +65,35 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
function sortHelper (a, b) {
|
||||
if (a.op === '_') {
|
||||
return -1
|
||||
} else if (b.op === '_') {
|
||||
return 1
|
||||
} else if (a.op === '*' || a.op === '/') {
|
||||
if (b.op === '*' || b.op === '/') {
|
||||
return 0
|
||||
}
|
||||
// a is higher precedence
|
||||
return -1
|
||||
} else {
|
||||
if (b.op === '*' || b.op === '/') {
|
||||
// b is higher precedence
|
||||
return 1
|
||||
}
|
||||
|
||||
// postive first
|
||||
if (a.op === '+' && b.op === '-') {
|
||||
return -1
|
||||
}
|
||||
if (a.op === '-' && b.op === '+') {
|
||||
return 1
|
||||
}
|
||||
// both are + or -
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
|
||||
const [lineItems, setLineItems] = useState({})
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
|
@ -77,7 +110,7 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = ()
|
|||
|
||||
const value = useMemo(() => {
|
||||
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
|
||||
const total = Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0)
|
||||
const total = Object.values(lines).sort(sortHelper).reduce((acc, { modifier }) => modifier(acc), 0)
|
||||
// freebies: there's only a base cost and we don't have enough sats
|
||||
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total && !me?.privates?.disableFreebies
|
||||
return {
|
||||
|
@ -145,7 +178,7 @@ function Receipt ({ lines, total }) {
|
|||
return (
|
||||
<Table className={styles.receipt} borderless size='sm'>
|
||||
<tbody>
|
||||
{Object.entries(lines).map(([key, { term, label, omit }]) => (
|
||||
{Object.entries(lines).sort(([, a], [, b]) => sortHelper(a, b)).map(([key, { term, label, omit }]) => (
|
||||
!omit &&
|
||||
<tr key={key}>
|
||||
<td>{term}</td>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'
|
|||
import { Form, Input, SubmitButton } from './form'
|
||||
import { useMe } from './me'
|
||||
import UpBolt from '@/svgs/bolt.svg'
|
||||
import { amountSchema } from '@/lib/validate'
|
||||
import { amountSchema, boostSchema } from '@/lib/validate'
|
||||
import { useToast } from './toast'
|
||||
import { useLightning } from './lightning'
|
||||
import { nextTip, defaultTipIncludingRandom } from './upvote'
|
||||
|
@ -12,6 +12,7 @@ import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
|
|||
import { usePaidMutation } from './use-paid-mutation'
|
||||
import { ACT_MUTATION } from '@/fragments/paidAction'
|
||||
import { meAnonSats } from '@/lib/apollo'
|
||||
import { BoostItemInput } from './adv-post-form'
|
||||
|
||||
const defaultTips = [100, 1000, 10_000, 100_000]
|
||||
|
||||
|
@ -53,7 +54,39 @@ const setItemMeAnonSats = ({ id, amount }) => {
|
|||
window.localStorage.setItem(storageKey, existingAmount + amount)
|
||||
}
|
||||
|
||||
export default function ItemAct ({ onClose, item, down, children, abortSignal }) {
|
||||
function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'BOOST' }) {
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
amount: step
|
||||
}}
|
||||
schema={boostSchema}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<BoostItemInput
|
||||
label='add boost'
|
||||
act
|
||||
name='amount'
|
||||
type='number'
|
||||
innerRef={inputRef}
|
||||
overrideValue={oValue}
|
||||
sub={item.sub}
|
||||
step={step}
|
||||
required
|
||||
autoFocus
|
||||
item={item}
|
||||
/>
|
||||
{children}
|
||||
<div className='d-flex mt-3'>
|
||||
<SubmitButton variant='success' className='ms-auto mt-1 px-4' value={act}>
|
||||
boost
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
|
||||
const inputRef = useRef(null)
|
||||
const { me } = useMe()
|
||||
const [oValue, setOValue] = useState()
|
||||
|
@ -62,7 +95,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
|
|||
inputRef.current?.focus()
|
||||
}, [onClose, item.id])
|
||||
|
||||
const act = useAct()
|
||||
const actor = useAct()
|
||||
const strike = useLightning()
|
||||
|
||||
const onSubmit = useCallback(async ({ amount }) => {
|
||||
|
@ -76,18 +109,18 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
|
|||
}
|
||||
}
|
||||
}
|
||||
const { error } = await act({
|
||||
const { error } = await actor({
|
||||
variables: {
|
||||
id: item.id,
|
||||
sats: Number(amount),
|
||||
act: down ? 'DONT_LIKE_THIS' : 'TIP'
|
||||
act
|
||||
},
|
||||
optimisticResponse: me
|
||||
? {
|
||||
act: {
|
||||
__typename: 'ItemActPaidAction',
|
||||
result: {
|
||||
id: item.id, sats: Number(amount), act: down ? 'DONT_LIKE_THIS' : 'TIP', path: item.path
|
||||
id: item.id, sats: Number(amount), act, path: item.path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -101,36 +134,40 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
|
|||
})
|
||||
if (error) throw error
|
||||
addCustomTip(Number(amount))
|
||||
}, [me, act, down, item.id, onClose, abortSignal, strike])
|
||||
}, [me, actor, act, item.id, onClose, abortSignal, strike])
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
amount: defaultTipIncludingRandom(me?.privates) || defaultTips[0],
|
||||
default: false
|
||||
}}
|
||||
schema={amountSchema}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Input
|
||||
label='amount'
|
||||
name='amount'
|
||||
type='number'
|
||||
innerRef={inputRef}
|
||||
overrideValue={oValue}
|
||||
required
|
||||
autoFocus
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<div>
|
||||
<Tips setOValue={setOValue} />
|
||||
</div>
|
||||
{children}
|
||||
<div className='d-flex mt-3'>
|
||||
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
return act === 'BOOST'
|
||||
? <BoostForm step={step} onSubmit={onSubmit} item={item} oValue={oValue} inputRef={inputRef} act={act}>{children}</BoostForm>
|
||||
: (
|
||||
<Form
|
||||
initial={{
|
||||
amount: defaultTipIncludingRandom(me?.privates) || defaultTips[0]
|
||||
}}
|
||||
schema={amountSchema}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Input
|
||||
label='amount'
|
||||
name='amount'
|
||||
type='number'
|
||||
innerRef={inputRef}
|
||||
overrideValue={oValue}
|
||||
step={step}
|
||||
required
|
||||
autoFocus
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Tips setOValue={setOValue} />
|
||||
</div>
|
||||
{children}
|
||||
<div className='d-flex mt-3'>
|
||||
<SubmitButton variant={act === 'DONT_LIKE_THIS' ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value={act}>
|
||||
{act === 'DONT_LIKE_THIS' ? 'downzap' : 'zap'}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</Form>)
|
||||
}
|
||||
|
||||
function modifyActCache (cache, { result, invoice }) {
|
||||
|
@ -156,6 +193,12 @@ function modifyActCache (cache, { result, invoice }) {
|
|||
return existingSats + sats
|
||||
}
|
||||
return existingSats
|
||||
},
|
||||
boost: (existingBoost = 0) => {
|
||||
if (act === 'BOOST') {
|
||||
return existingBoost + sats
|
||||
}
|
||||
return existingBoost
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { string } from 'yup'
|
||||
import Toc from './table-of-contents'
|
||||
import Badge from 'react-bootstrap/Badge'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
import Image from 'react-bootstrap/Image'
|
||||
import { SearchTitle } from './item'
|
||||
|
@ -11,6 +10,8 @@ import EmailIcon from '@/svgs/mail-open-line.svg'
|
|||
import Share from './share'
|
||||
import Hat from './hat'
|
||||
import { MEDIA_URL } from '@/lib/constants'
|
||||
import { abbrNum } from '@/lib/format'
|
||||
import { Badge } from 'react-bootstrap'
|
||||
|
||||
export default function ItemJob ({ item, toc, rank, children }) {
|
||||
const isEmail = string().email().isValidSync(item.url)
|
||||
|
@ -50,6 +51,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||
</>}
|
||||
<wbr />
|
||||
<span> \ </span>
|
||||
{item.boost > 0 && <span>{abbrNum(item.boost)} boost \ </span>}
|
||||
<span>
|
||||
<Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'>
|
||||
@{item.user.name}<Hat className='ms-1 fill-grey' user={item.user} height={12} width={12} />
|
||||
|
@ -59,17 +61,21 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||
{timeSince(new Date(item.createdAt))}
|
||||
</Link>
|
||||
</span>
|
||||
{item.mine &&
|
||||
{item.subName &&
|
||||
<Link href={`/~${item.subName}`}>
|
||||
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
|
||||
</Link>}
|
||||
{item.status === 'STOPPED' &&
|
||||
<>{' '}<Badge bg='info' className={styles.badge}>stopped</Badge></>}
|
||||
{item.mine && !item.deletedAt &&
|
||||
(
|
||||
<>
|
||||
<wbr />
|
||||
<span> \ </span>
|
||||
<Link href={`/items/${item.id}/edit`} className='text-reset'>
|
||||
<Link href={`/items/${item.id}/edit`} className='text-reset fw-bold'>
|
||||
edit
|
||||
</Link>
|
||||
{item.status !== 'ACTIVE' && <span className='ms-1 fw-bold text-boost'> {item.status}</span>}
|
||||
</>)}
|
||||
{item.maxBid > 0 && item.status === 'ACTIVE' && <Badge className={`${styles.newComment} ms-1`}>PROMOTED</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
{toc &&
|
||||
|
|
|
@ -24,6 +24,7 @@ import removeMd from 'remove-markdown'
|
|||
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, parseInternalLinks } from '@/lib/url'
|
||||
import ItemPopover from './item-popover'
|
||||
import { useMe } from './me'
|
||||
import Boost from './boost-button'
|
||||
|
||||
function onItemClick (e, router, item) {
|
||||
const viewedAt = commentsViewedAt(item)
|
||||
|
@ -105,11 +106,13 @@ export default function Item ({
|
|||
<div className={classNames(styles.item, itemClassName)}>
|
||||
{item.position && (pinnable || !item.subName)
|
||||
? <Pin width={24} height={24} className={styles.pin} />
|
||||
: item.meDontLikeSats > item.meSats
|
||||
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||
: Number(item.user?.id) === USER_ID.ad
|
||||
? <AdIcon width={24} height={24} className={styles.ad} />
|
||||
: <UpVote item={item} className={styles.upvote} />}
|
||||
: item.mine
|
||||
? <Boost item={item} className={styles.upvote} />
|
||||
: item.meDontLikeSats > item.meSats
|
||||
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||
: Number(item.user?.id) === USER_ID.ad
|
||||
? <AdIcon width={24} height={24} className={styles.ad} />
|
||||
: <UpVote item={item} className={styles.upvote} />}
|
||||
<div className={styles.hunk}>
|
||||
<div className={`${styles.main} flex-wrap`}>
|
||||
<Link
|
||||
|
|
|
@ -46,6 +46,12 @@ a.title:visited {
|
|||
margin-bottom: .15rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
vertical-align: middle;
|
||||
margin-top: -1px;
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
|
||||
.newComment {
|
||||
color: var(--theme-grey) !important;
|
||||
background: var(--theme-clickToContextColor) !important;
|
||||
|
|
|
@ -15,7 +15,7 @@ export default function Items ({ ssrData, variables = {}, query, destructureData
|
|||
const Foooter = Footer || MoreFooter
|
||||
const dat = useData(data, ssrData)
|
||||
|
||||
const { items, pins, cursor } = useMemo(() => {
|
||||
const { items, pins, ad, cursor } = useMemo(() => {
|
||||
if (!dat) return {}
|
||||
if (destructureData) {
|
||||
return destructureData(dat)
|
||||
|
@ -50,6 +50,7 @@ export default function Items ({ ssrData, variables = {}, query, destructureData
|
|||
return (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{ad && <ListItem item={ad} ad />}
|
||||
{itemsWithPins.filter(filter).map((item, i) => (
|
||||
<ListItem key={`${item.id}-${i + 1}`} item={item} rank={rank && i + 1} itemClassName={variables.includeComments ? 'py-2' : ''} pinnable={isHome ? false : pins?.length > 0} />
|
||||
))}
|
||||
|
|
|
@ -1,46 +1,41 @@
|
|||
import { Checkbox, Form, Input, MarkdownInput } from './form'
|
||||
import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form'
|
||||
import Row from 'react-bootstrap/Row'
|
||||
import Col from 'react-bootstrap/Col'
|
||||
import InputGroup from 'react-bootstrap/InputGroup'
|
||||
import Image from 'react-bootstrap/Image'
|
||||
import BootstrapForm from 'react-bootstrap/Form'
|
||||
import Alert from 'react-bootstrap/Alert'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Info from './info'
|
||||
import AccordianItem from './accordian-item'
|
||||
import styles from '@/styles/post.module.css'
|
||||
import { useLazyQuery, gql } from '@apollo/client'
|
||||
import Link from 'next/link'
|
||||
import { usePrice } from './price'
|
||||
import Avatar from './avatar'
|
||||
import { jobSchema } from '@/lib/validate'
|
||||
import { MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants'
|
||||
import { ItemButtonBar } from './post'
|
||||
import { useFormikContext } from 'formik'
|
||||
import { BOOST_MIN, BOOST_MULT, MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants'
|
||||
import { UPSERT_JOB } from '@/fragments/paidAction'
|
||||
import useItemSubmit from './use-item-submit'
|
||||
|
||||
function satsMin2Mo (minute) {
|
||||
return minute * 30 * 24 * 60
|
||||
}
|
||||
|
||||
function PriceHint ({ monthly }) {
|
||||
const { price, fiatSymbol } = usePrice()
|
||||
|
||||
if (!price || !monthly) {
|
||||
return null
|
||||
}
|
||||
const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
|
||||
const fiat = fixed((price / 100000000) * monthly, 0)
|
||||
|
||||
return <span className='text-muted'>{monthly} sats/mo which is {fiatSymbol}{fiat}/mo</span>
|
||||
}
|
||||
import { BoostInput } from './adv-post-form'
|
||||
import { numWithUnits, giveOrdinalSuffix } from '@/lib/format'
|
||||
import useDebounceCallback from './use-debounce-callback'
|
||||
import FeeButton from './fee-button'
|
||||
import CancelButton from './cancel-button'
|
||||
|
||||
// need to recent list items
|
||||
export default function JobForm ({ item, sub }) {
|
||||
const storageKeyPrefix = item ? undefined : `${sub.name}-job`
|
||||
const [logoId, setLogoId] = useState(item?.uploadId)
|
||||
|
||||
const [getAuctionPosition, { data }] = useLazyQuery(gql`
|
||||
query AuctionPosition($id: ID, $boost: Int) {
|
||||
auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, boost: $boost)
|
||||
}`,
|
||||
{ fetchPolicy: 'cache-and-network' })
|
||||
|
||||
const getPositionDebounce = useDebounceCallback((...args) => getAuctionPosition(...args), 1000, [getAuctionPosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (item?.boost) {
|
||||
getPositionDebounce({ variables: { boost: item.boost, id: item.id } })
|
||||
}
|
||||
}, [item?.boost])
|
||||
|
||||
const extraValues = logoId ? { logo: Number(logoId) } : {}
|
||||
const onSubmit = useItemSubmit(UPSERT_JOB, { item, sub, extraValues })
|
||||
|
||||
|
@ -53,13 +48,13 @@ export default function JobForm ({ item, sub }) {
|
|||
company: item?.company || '',
|
||||
location: item?.location || '',
|
||||
remote: item?.remote || false,
|
||||
boost: item?.boost || '',
|
||||
text: item?.text || '',
|
||||
url: item?.url || '',
|
||||
maxBid: item?.maxBid || 0,
|
||||
stop: false,
|
||||
start: false
|
||||
}}
|
||||
schema={jobSchema}
|
||||
schema={jobSchema({ existingBoost: item?.boost })}
|
||||
storageKeyPrefix={storageKeyPrefix}
|
||||
requireSession
|
||||
onSubmit={onSubmit}
|
||||
|
@ -115,130 +110,46 @@ export default function JobForm ({ item, sub }) {
|
|||
required
|
||||
clear
|
||||
/>
|
||||
<PromoteJob item={item} sub={sub} />
|
||||
{item && <StatusControl item={item} />}
|
||||
<ItemButtonBar itemId={item?.id} canDelete={false} />
|
||||
<BoostInput
|
||||
label={
|
||||
<div className='d-flex align-items-center'>boost
|
||||
<Info>
|
||||
<ol className='line-height-md'>
|
||||
<li>Boost ranks jobs higher based on the amount</li>
|
||||
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
|
||||
<li>Boost must be divisible by {numWithUnits(BOOST_MULT, { abbreviate: false })}</li>
|
||||
<li>100% of boost goes to the territory founder and top stackers as rewards</li>
|
||||
</ol>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
hint={<span className='text-muted'>{data?.auctionPosition ? `your job will rank ${giveOrdinalSuffix(data.auctionPosition)}` : 'higher boost ranks your job higher'}</span>}
|
||||
onChange={(_, e) => getPositionDebounce({ variables: { boost: Number(e.target.value), id: item?.id } })}
|
||||
/>
|
||||
<JobButtonBar itemId={item?.id} />
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const FormStatus = {
|
||||
DIRTY: 'dirty',
|
||||
ERROR: 'error'
|
||||
}
|
||||
|
||||
function PromoteJob ({ item, sub }) {
|
||||
const formik = useFormikContext()
|
||||
const [show, setShow] = useState(false)
|
||||
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0))
|
||||
const [getAuctionPosition, { data }] = useLazyQuery(gql`
|
||||
query AuctionPosition($id: ID, $bid: Int!) {
|
||||
auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, bid: $bid)
|
||||
}`,
|
||||
{ fetchPolicy: 'cache-and-network' })
|
||||
const position = data?.auctionPosition
|
||||
|
||||
useEffect(() => {
|
||||
const initialMaxBid = Number(item?.maxBid) || 0
|
||||
getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
|
||||
setMonthly(satsMin2Mo(initialMaxBid))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (formik?.values?.maxBid !== 0) {
|
||||
setShow(FormStatus.DIRTY)
|
||||
}
|
||||
}, [formik?.values])
|
||||
|
||||
useEffect(() => {
|
||||
const hasMaxBidError = !!formik?.errors?.maxBid
|
||||
// if it's open we don't want to collapse on submit
|
||||
setShow(show => show || (hasMaxBidError && formik?.isSubmitting && FormStatus.ERROR))
|
||||
}, [formik?.isSubmitting])
|
||||
|
||||
export function JobButtonBar ({
|
||||
itemId, disable, className, children, handleStop, onCancel, hasCancel = true,
|
||||
createText = 'post', editText = 'save', stopText = 'remove'
|
||||
}) {
|
||||
return (
|
||||
<AccordianItem
|
||||
show={show}
|
||||
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>}
|
||||
body={
|
||||
<>
|
||||
<Input
|
||||
label={
|
||||
<div className='d-flex align-items-center'>bid
|
||||
<Info>
|
||||
<ol>
|
||||
<li>The higher your bid the higher your job will rank</li>
|
||||
<li>You can increase, decrease, or remove your bid at anytime</li>
|
||||
<li>You can edit or stop your job at anytime</li>
|
||||
<li>If you run out of sats, your job will stop being promoted until you fill your wallet again</li>
|
||||
</ol>
|
||||
</Info>
|
||||
<small className='text-muted ms-2'>optional</small>
|
||||
</div>
|
||||
}
|
||||
name='maxBid'
|
||||
onChange={async (formik, e) => {
|
||||
if (e.target.value >= 0 && e.target.value <= 100000000) {
|
||||
setMonthly(satsMin2Mo(e.target.value))
|
||||
getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } })
|
||||
} else {
|
||||
setMonthly(satsMin2Mo(0))
|
||||
}
|
||||
}}
|
||||
append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>}
|
||||
hint={<PriceHint monthly={monthly} />}
|
||||
<div className={`mt-3 ${className}`}>
|
||||
<div className='d-flex justify-content-between'>
|
||||
{itemId &&
|
||||
<SubmitButton valueName='status' value='STOPPED' variant='grey-medium'>{stopText}</SubmitButton>}
|
||||
{children}
|
||||
<div className='d-flex align-items-center ms-auto'>
|
||||
{hasCancel && <CancelButton onClick={onCancel} />}
|
||||
<FeeButton
|
||||
text={itemId ? editText : createText}
|
||||
variant='secondary'
|
||||
disabled={disable}
|
||||
/>
|
||||
<><div className='fw-bold text-muted'>This bid puts your job in position: {position}</div></>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusControl ({ item }) {
|
||||
let StatusComp
|
||||
|
||||
if (item.status !== 'STOPPED') {
|
||||
StatusComp = () => {
|
||||
return (
|
||||
<>
|
||||
|
||||
<AccordianItem
|
||||
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>I want to stop my job</div>}
|
||||
headerColor='var(--bs-danger)'
|
||||
body={
|
||||
<Checkbox
|
||||
label={<div className='fw-bold text-danger'>stop my job</div>} name='stop' inline
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
} else if (item.status === 'STOPPED') {
|
||||
StatusComp = () => {
|
||||
return (
|
||||
<AccordianItem
|
||||
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>I want to resume my job</div>}
|
||||
headerColor='var(--bs-success)'
|
||||
body={
|
||||
<Checkbox
|
||||
label={<div className='fw-bold text-success'>resume my job</div>} name='start' inline
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='my-3 border border-3 rounded'>
|
||||
<div className='p-3'>
|
||||
<BootstrapForm.Label>job control</BootstrapForm.Label>
|
||||
{item.status === 'NOSATS' &&
|
||||
<Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' className='text-reset text-underline'>fund your wallet</Link> or reduce bid to continue promoting your job</Alert>}
|
||||
<StatusComp />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -163,7 +163,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||
}
|
||||
}}
|
||||
/>
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item}>
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub}>
|
||||
<MarkdownInput
|
||||
label='context'
|
||||
name='text'
|
||||
|
|
|
@ -401,22 +401,24 @@ export function Sorts ({ sub, prefix, className }) {
|
|||
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item className={className}>
|
||||
<Link href={prefix + '/random'} passHref legacyBehavior>
|
||||
<Nav.Link eventKey='random' className={styles.navLink}>random</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
{sub !== 'jobs' &&
|
||||
<Nav.Item className={className}>
|
||||
<Link
|
||||
href={{
|
||||
pathname: '/~/top/[type]/[when]',
|
||||
query: { type: 'posts', when: 'day', sub }
|
||||
}} as={prefix + '/top/posts/day'} passHref legacyBehavior
|
||||
>
|
||||
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>}
|
||||
<>
|
||||
<Nav.Item className={className}>
|
||||
<Link href={prefix + '/random'} passHref legacyBehavior>
|
||||
<Nav.Link eventKey='random' className={styles.navLink}>random</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item className={className}>
|
||||
<Link
|
||||
href={{
|
||||
pathname: '/~/top/[type]/[when]',
|
||||
query: { type: 'posts', when: 'day', sub }
|
||||
}} as={prefix + '/top/posts/day'} passHref legacyBehavior
|
||||
>
|
||||
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
</>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -62,7 +62,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||
: null}
|
||||
maxLength={MAX_POLL_CHOICE_LENGTH}
|
||||
/>
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item}>
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub}>
|
||||
<DateTimeInput
|
||||
isClearable
|
||||
label='poll expiration'
|
||||
|
|
|
@ -187,7 +187,7 @@ export default function Post ({ sub }) {
|
|||
export function ItemButtonBar ({
|
||||
itemId, canDelete = true, disable,
|
||||
className, children, onDelete, onCancel, hasCancel = true,
|
||||
createText = 'post', editText = 'save'
|
||||
createText = 'post', editText = 'save', deleteText = 'delete'
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
|
@ -199,7 +199,7 @@ export function ItemButtonBar ({
|
|||
itemId={itemId}
|
||||
onDelete={onDelete || (() => router.push(`/items/${itemId}`))}
|
||||
>
|
||||
<Button variant='grey-medium'>delete</Button>
|
||||
<Button variant='grey-medium'>{deleteText}</Button>
|
||||
</Delete>}
|
||||
{children}
|
||||
<div className='d-flex align-items-center ms-auto'>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -12,6 +12,7 @@ import Popover from 'react-bootstrap/Popover'
|
|||
import { useShowModal } from './modal'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import classNames from 'classnames'
|
||||
|
||||
const UpvotePopover = ({ target, show, handleClose }) => {
|
||||
const { me } = useMe()
|
||||
|
@ -226,7 +227,14 @@ export default function UpVote ({ item, className }) {
|
|||
}
|
||||
}
|
||||
|
||||
const fillColor = hover || pending ? nextColor : color
|
||||
const fillColor = meSats && (hover || pending ? nextColor : color)
|
||||
|
||||
const style = useMemo(() => (fillColor
|
||||
? {
|
||||
fill: fillColor,
|
||||
filter: `drop-shadow(0 0 6px ${fillColor}90)`
|
||||
}
|
||||
: undefined), [fillColor])
|
||||
|
||||
return (
|
||||
<div ref={ref} className='upvoteParent'>
|
||||
|
@ -236,7 +244,7 @@ export default function UpVote ({ item, className }) {
|
|||
>
|
||||
<ActionTooltip notForm disable={disabled} overlayText={overlayText}>
|
||||
<div
|
||||
className={`${disabled ? styles.noSelfTips : ''} ${styles.upvoteWrapper}`}
|
||||
className={classNames(disabled && styles.noSelfTips, styles.upvoteWrapper)}
|
||||
>
|
||||
<UpBolt
|
||||
onPointerEnter={() => setHover(true)}
|
||||
|
@ -244,19 +252,12 @@ export default function UpVote ({ item, className }) {
|
|||
onTouchEnd={() => setHover(false)}
|
||||
width={26}
|
||||
height={26}
|
||||
className={
|
||||
`${styles.upvote}
|
||||
${className || ''}
|
||||
${disabled ? styles.noSelfTips : ''}
|
||||
${meSats ? styles.voted : ''}
|
||||
${pending ? styles.pending : ''}`
|
||||
}
|
||||
style={meSats || hover || pending
|
||||
? {
|
||||
fill: fillColor,
|
||||
filter: `drop-shadow(0 0 6px ${fillColor}90)`
|
||||
}
|
||||
: undefined}
|
||||
className={classNames(styles.upvote,
|
||||
className,
|
||||
disabled && styles.noSelfTips,
|
||||
meSats && styles.voted,
|
||||
pending && styles.pending)}
|
||||
style={style}
|
||||
/>
|
||||
</div>
|
||||
</ActionTooltip>
|
||||
|
|
|
@ -13,8 +13,7 @@
|
|||
}
|
||||
|
||||
.noSelfTips {
|
||||
fill: transparent !important;
|
||||
filter: none !important;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.upvoteWrapper:not(.noSelfTips):hover {
|
||||
|
|
|
@ -24,7 +24,7 @@ export default function useItemSubmit (mutation,
|
|||
const { me } = useMe()
|
||||
|
||||
return useCallback(
|
||||
async ({ boost, crosspost, title, options, bounty, maxBid, start, stop, ...values }, { resetForm }) => {
|
||||
async ({ boost, crosspost, title, options, bounty, status, ...values }, { resetForm }) => {
|
||||
if (options) {
|
||||
// remove existing poll options since else they will be appended as duplicates
|
||||
options = options.slice(item?.poll?.options?.length || 0).filter(o => o.trim().length > 0)
|
||||
|
@ -44,10 +44,9 @@ export default function useItemSubmit (mutation,
|
|||
variables: {
|
||||
id: item?.id,
|
||||
sub: item?.subName || sub?.name,
|
||||
boost: boost ? Number(boost) : undefined,
|
||||
boost: boost ? Number(boost) : item?.boost ? Number(item.boost) : undefined,
|
||||
bounty: bounty ? Number(bounty) : undefined,
|
||||
maxBid: (maxBid || Number(maxBid) === 0) ? Number(maxBid) : undefined,
|
||||
status: start ? 'ACTIVE' : stop ? 'STOPPED' : undefined,
|
||||
status: status === 'STOPPED' ? 'STOPPED' : 'ACTIVE',
|
||||
title: title?.trim(),
|
||||
options,
|
||||
...values,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -133,11 +133,11 @@ export const UPSERT_DISCUSSION = gql`
|
|||
export const UPSERT_JOB = gql`
|
||||
${PAID_ACTION}
|
||||
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!,
|
||||
$location: String, $remote: Boolean, $text: String!, $url: String!, $maxBid: Int!,
|
||||
$location: String, $remote: Boolean, $text: String!, $url: String!, $boost: Int,
|
||||
$status: String, $logo: Int) {
|
||||
upsertJob(sub: $sub, id: $id, title: $title, company: $company,
|
||||
location: $location, remote: $remote, text: $text,
|
||||
url: $url, maxBid: $maxBid, status: $status, logo: $logo) {
|
||||
url: $url, boost: $boost, status: $status, logo: $logo) {
|
||||
result {
|
||||
id
|
||||
deleteScheduledAt
|
||||
|
@ -218,8 +218,8 @@ export const CREATE_COMMENT = gql`
|
|||
export const UPDATE_COMMENT = gql`
|
||||
${ITEM_PAID_ACTION_FIELDS}
|
||||
${PAID_ACTION}
|
||||
mutation upsertComment($id: ID!, $text: String!, ${HASH_HMAC_INPUT_1}) {
|
||||
upsertComment(id: $id, text: $text, ${HASH_HMAC_INPUT_2}) {
|
||||
mutation upsertComment($id: ID!, $text: String!, $boost: Int, ${HASH_HMAC_INPUT_1}) {
|
||||
upsertComment(id: $id, text: $text, boost: $boost, ${HASH_HMAC_INPUT_2}) {
|
||||
...ItemPaidActionFields
|
||||
...PaidActionFields
|
||||
}
|
||||
|
|
|
@ -87,6 +87,9 @@ export const SUB_ITEMS = gql`
|
|||
...CommentItemExtFields @include(if: $includeComments)
|
||||
position
|
||||
}
|
||||
ad {
|
||||
...ItemFields
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -550,14 +550,13 @@ export const commentSchema = object({
|
|||
text: textValidator(MAX_COMMENT_TEXT_LENGTH).required('required')
|
||||
})
|
||||
|
||||
export const jobSchema = object({
|
||||
export const jobSchema = args => object({
|
||||
title: titleValidator,
|
||||
company: string().required('required').trim(),
|
||||
text: textValidator(MAX_POST_TEXT_LENGTH).required('required'),
|
||||
url: string()
|
||||
.or([string().email(), string().url()], 'invalid url or email')
|
||||
.required('required'),
|
||||
maxBid: intValidator.min(0, 'must be at least 0').required('required'),
|
||||
location: string().test(
|
||||
'no-remote',
|
||||
"don't write remote, just check the box",
|
||||
|
@ -565,7 +564,8 @@ export const jobSchema = object({
|
|||
.when('remote', {
|
||||
is: (value) => !value,
|
||||
then: schema => schema.required('required').trim()
|
||||
})
|
||||
}),
|
||||
...advPostSchemaMembers(args)
|
||||
})
|
||||
|
||||
export const emailSchema = object({
|
||||
|
@ -585,9 +585,26 @@ export const amountSchema = object({
|
|||
amount: intValidator.required('required').positive('must be positive')
|
||||
})
|
||||
|
||||
export const boostValidator = intValidator
|
||||
.min(BOOST_MULT, `must be at least ${BOOST_MULT}`).test({
|
||||
name: 'boost',
|
||||
test: async boost => boost % BOOST_MULT === 0,
|
||||
message: `must be divisble be ${BOOST_MULT}`
|
||||
})
|
||||
|
||||
export const boostSchema = object({
|
||||
amount: boostValidator.required('required').positive('must be positive')
|
||||
})
|
||||
|
||||
export const actSchema = object({
|
||||
sats: intValidator.required('required').positive('must be positive'),
|
||||
act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS'])
|
||||
sats: intValidator.required('required').positive('must be positive')
|
||||
.when(['act'], ([act], schema) => {
|
||||
if (act === 'BOOST') {
|
||||
return boostValidator
|
||||
}
|
||||
return schema
|
||||
}),
|
||||
act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS', 'BOOST'])
|
||||
})
|
||||
|
||||
export const settingsSchema = object().shape({
|
||||
|
@ -618,7 +635,7 @@ export const settingsSchema = object().shape({
|
|||
string().nullable().matches(NOSTR_PUBKEY_HEX, 'must be 64 hex chars'),
|
||||
string().nullable().matches(NOSTR_PUBKEY_BECH32, 'invalid bech32 encoding')], 'invalid pubkey'),
|
||||
nostrRelays: array().of(
|
||||
string().ws()
|
||||
string().ws().transform(relay => relay.startsWith('wss://') ? relay : `wss://${relay}`)
|
||||
).max(NOSTR_MAX_RELAY_NUM,
|
||||
({ max, value }) => `${Math.abs(max - value.length)} too many`),
|
||||
hideBookmarks: boolean(),
|
||||
|
|
|
@ -49,6 +49,7 @@ export default function PostEdit ({ ssrData }) {
|
|||
existingBoost: {
|
||||
label: 'old boost',
|
||||
term: `- ${item.boost}`,
|
||||
op: '-',
|
||||
modifier: cost => cost - item.boost
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,12 +23,15 @@ import { useMemo } from 'react'
|
|||
import { CompactLongCountdown } from '@/components/countdown'
|
||||
import { usePaidMutation } from '@/components/use-paid-mutation'
|
||||
import { DONATE } from '@/fragments/paidAction'
|
||||
import { ITEM_FULL_FIELDS } from '@/fragments/items'
|
||||
import { ListItem } from '@/components/items'
|
||||
|
||||
const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), {
|
||||
loading: () => <GrowthPieChartSkeleton />
|
||||
})
|
||||
|
||||
const REWARDS_FULL = gql`
|
||||
${ITEM_FULL_FIELDS}
|
||||
{
|
||||
rewards {
|
||||
total
|
||||
|
@ -37,6 +40,9 @@ const REWARDS_FULL = gql`
|
|||
name
|
||||
value
|
||||
}
|
||||
ad {
|
||||
...ItemFullFields
|
||||
}
|
||||
leaderboard {
|
||||
users {
|
||||
id
|
||||
|
@ -97,7 +103,7 @@ export default function Rewards ({ ssrData }) {
|
|||
const { data } = useQuery(REWARDS_FULL)
|
||||
const dat = useData(data, ssrData)
|
||||
|
||||
let { rewards: [{ total, sources, time, leaderboard }] } = useMemo(() => {
|
||||
let { rewards: [{ total, sources, time, leaderboard, ad }] } = useMemo(() => {
|
||||
return dat || { rewards: [{}] }
|
||||
}, [dat])
|
||||
|
||||
|
@ -124,12 +130,13 @@ export default function Rewards ({ ssrData }) {
|
|||
|
||||
return (
|
||||
<Layout footerLinks>
|
||||
<h4 className='pt-3 align-self-center text-reset'>
|
||||
<small className='text-muted'>rewards are sponsored by ...</small>
|
||||
<Link className='text-reset ms-2' href='/items/141924' style={{ lineHeight: 1.5, textDecoration: 'underline' }}>
|
||||
SN is hiring
|
||||
</Link>
|
||||
</h4>
|
||||
{ad &&
|
||||
<div className='pt-3 align-self-center' style={{ maxWidth: '480px', width: '100%' }}>
|
||||
<div className='fw-bold text-muted pb-2'>
|
||||
top boost this month
|
||||
</div>
|
||||
<ListItem item={ad} />
|
||||
</div>}
|
||||
<Row className='pb-3'>
|
||||
<Col lg={leaderboard?.users && 5}>
|
||||
<div
|
||||
|
|
|
@ -170,7 +170,9 @@ export default function Settings ({ ssrData }) {
|
|||
}
|
||||
}
|
||||
|
||||
const nostrRelaysFiltered = nostrRelays?.filter(word => word.trim().length > 0)
|
||||
const nostrRelaysFiltered = nostrRelays
|
||||
?.filter(word => word.trim().length > 0)
|
||||
.map(relay => relay.startsWith('wss://') ? relay : `wss://${relay}`)
|
||||
|
||||
try {
|
||||
await setSettings({
|
||||
|
|
|
@ -2,16 +2,13 @@
|
|||
margin: 1rem 0;
|
||||
justify-content: start;
|
||||
font-size: 110%;
|
||||
gap: 0 0.5rem;
|
||||
}
|
||||
|
||||
.nav :global .nav-link {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.nav :global .nav-item:not(:first-child) {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.nav :global .active {
|
||||
border-bottom: 2px solid var(--bs-primary);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
UPDATE "Sub" SET "rewardsPct" = 30;
|
||||
|
||||
-- account for comments in rewards
|
||||
CREATE OR REPLACE FUNCTION rewards(min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT)
|
||||
RETURNS TABLE (
|
||||
t TIMESTAMP(3), total BIGINT, donations BIGINT, fees BIGINT, boost BIGINT, jobs BIGINT, anons_stack BIGINT
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT period.t,
|
||||
coalesce(FLOOR(sum(msats)), 0)::BIGINT as total,
|
||||
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'DONATION')), 0)::BIGINT as donations,
|
||||
coalesce(FLOOR(sum(msats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION', 'ANON'))), 0)::BIGINT as fees,
|
||||
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'BOOST')), 0)::BIGINT as boost,
|
||||
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'STREAM')), 0)::BIGINT as jobs,
|
||||
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'ANON')), 0)::BIGINT as anons_stack
|
||||
FROM generate_series(min, max, ival) period(t),
|
||||
LATERAL
|
||||
(
|
||||
(SELECT
|
||||
("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) * COALESCE("Sub"."rewardsPct", 100) * 0.01 as msats,
|
||||
act::text as type
|
||||
FROM "ItemAct"
|
||||
JOIN "Item" ON "Item"."id" = "ItemAct"."itemId"
|
||||
LEFT JOIN "Item" root ON "Item"."rootId" = root.id
|
||||
JOIN "Sub" ON "Sub"."name" = COALESCE(root."subName", "Item"."subName")
|
||||
LEFT JOIN "ReferralAct" ON "ReferralAct"."itemActId" = "ItemAct".id
|
||||
WHERE date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
|
||||
AND "ItemAct".act <> 'TIP'
|
||||
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID'))
|
||||
UNION ALL
|
||||
(SELECT sats * 1000 as msats, 'DONATION' as type
|
||||
FROM "Donation"
|
||||
WHERE date_trunc(date_part, "Donation".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t)
|
||||
UNION ALL
|
||||
-- any earnings from anon's stack that are not forwarded to other users
|
||||
(SELECT "ItemAct".msats, 'ANON' as type
|
||||
FROM "Item"
|
||||
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
|
||||
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id
|
||||
WHERE "Item"."userId" = 27 AND "ItemAct".act = 'TIP'
|
||||
AND date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
|
||||
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
|
||||
GROUP BY "ItemAct".id, "ItemAct".msats
|
||||
HAVING COUNT("ItemForward".id) = 0)
|
||||
) x
|
||||
GROUP BY period.t;
|
||||
END;
|
||||
$$;
|
|
@ -0,0 +1,54 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Item" ADD COLUMN "oldBoost" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION expire_boost_jobs()
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein)
|
||||
SELECT 'expireBoost', jsonb_build_object('id', "Item".id), 21, true, now(), interval '1 days'
|
||||
FROM "Item"
|
||||
WHERE "Item".boost > 0 ON CONFLICT DO NOTHING;
|
||||
return 0;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
return 0;
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT expire_boost_jobs();
|
||||
DROP FUNCTION IF EXISTS expire_boost_jobs;
|
||||
|
||||
-- fold all STREAM "ItemAct" into a single row per item (it's defunct)
|
||||
INSERT INTO "ItemAct" (created_at, updated_at, msats, act, "itemId", "userId")
|
||||
SELECT MAX("ItemAct".created_at), MAX("ItemAct".updated_at), sum("ItemAct".msats), 'BOOST', "ItemAct"."itemId", "ItemAct"."userId"
|
||||
FROM "ItemAct"
|
||||
WHERE "ItemAct".act = 'STREAM'
|
||||
GROUP BY "ItemAct"."itemId", "ItemAct"."userId";
|
||||
|
||||
-- drop all STREAM "ItemAct" rows
|
||||
DELETE FROM "ItemAct"
|
||||
WHERE "ItemAct".act = 'STREAM';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "InvoiceActionType" ADD VALUE 'BOOST';
|
||||
|
||||
-- increase boost per vote
|
||||
CREATE OR REPLACE VIEW zap_rank_personal_constants AS
|
||||
SELECT
|
||||
10000.0 AS boost_per_vote,
|
||||
1.2 AS vote_power,
|
||||
1.3 AS vote_decay,
|
||||
3.0 AS age_wait_hours,
|
||||
0.5 AS comment_scaler,
|
||||
1.2 AS boost_power,
|
||||
1.6 AS boost_decay,
|
||||
616 AS global_viewer_id,
|
||||
interval '7 days' AS item_age_bound,
|
||||
interval '7 days' AS user_last_seen_bound,
|
||||
0.9 AS max_personal_viewer_vote_ratio,
|
||||
0.1 AS min_viewer_votes;
|
||||
|
||||
DROP FUNCTION IF EXISTS run_auction(item_id INTEGER);
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -10,7 +10,7 @@ const MAX_OUTGOING_CLTV_DELTA = 500 // the maximum cltv delta we'll allow for th
|
|||
export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice
|
||||
const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request
|
||||
const MAX_FEE_ESTIMATE_PERCENT = 0.025 // the maximum fee relative to outgoing we'll allow for the fee estimate
|
||||
const ZAP_SYBIL_FEE_MULT = 10 / 9 // the fee for the zap sybil service
|
||||
const ZAP_SYBIL_FEE_MULT = 10 / 7 // the fee for the zap sybil service
|
||||
|
||||
/*
|
||||
The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice.
|
||||
|
|
|
@ -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 })
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -22,7 +22,6 @@ const ITEM_SEARCH_FIELDS = gql`
|
|||
subName
|
||||
}
|
||||
status
|
||||
maxBid
|
||||
company
|
||||
location
|
||||
remote
|
||||
|
|
Loading…
Reference in New Issue