Compare commits
	
		
			8 Commits
		
	
	
		
			ae579cbec3
			...
			3310925155
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 3310925155 | ||
|  | 020b4c5eea | ||
|  | e323ed27c6 | ||
|  | d17929f2c5 | ||
|  | 5f0494de30 | ||
|  | beba2f4794 | ||
|  | dcbe83f155 | ||
|  | 5088673b84 | 
							
								
								
									
										77
									
								
								api/paidAction/boost.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								api/paidAction/boost.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,77 @@ | ||||
| import { msatsToSats, satsToMsats } from '@/lib/format' | ||||
| 
 | ||||
| export const anonable = false | ||||
| export const supportsPessimism = false | ||||
| export const supportsOptimism = true | ||||
| 
 | ||||
| export async function getCost ({ sats }) { | ||||
|   return satsToMsats(sats) | ||||
| } | ||||
| 
 | ||||
| export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) { | ||||
|   itemId = parseInt(itemId) | ||||
| 
 | ||||
|   let invoiceData = {} | ||||
|   if (invoiceId) { | ||||
|     invoiceData = { invoiceId, invoiceActionState: 'PENDING' } | ||||
|     // store a reference to the item in the invoice
 | ||||
|     await tx.invoice.update({ | ||||
|       where: { id: invoiceId }, | ||||
|       data: { actionId: itemId } | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   const act = await tx.itemAct.create({ data: { msats: cost, itemId, userId: me.id, act: 'BOOST', ...invoiceData } }) | ||||
| 
 | ||||
|   const [{ path }] = await tx.$queryRaw` | ||||
|     SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER` | ||||
|   return { id: itemId, sats, act: 'BOOST', path, actId: act.id } | ||||
| } | ||||
| 
 | ||||
| export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { | ||||
|   await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) | ||||
|   const [{ id, path }] = await tx.$queryRaw` | ||||
|     SELECT "Item".id, ltree2text(path) as path | ||||
|     FROM "Item" | ||||
|     JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" | ||||
|     WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` | ||||
|   return { id, sats: msatsToSats(cost), act: 'BOOST', path } | ||||
| } | ||||
| 
 | ||||
| export async function onPaid ({ invoice, actId }, { models, tx }) { | ||||
|   let itemAct | ||||
|   if (invoice) { | ||||
|     await tx.itemAct.updateMany({ | ||||
|       where: { invoiceId: invoice.id }, | ||||
|       data: { | ||||
|         invoiceActionState: 'PAID' | ||||
|       } | ||||
|     }) | ||||
|     itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id } }) | ||||
|   } else if (actId) { | ||||
|     itemAct = await tx.itemAct.findFirst({ where: { id: actId } }) | ||||
|   } else { | ||||
|     throw new Error('No invoice or actId') | ||||
|   } | ||||
| 
 | ||||
|   // increment boost on item
 | ||||
|   await tx.item.update({ | ||||
|     where: { id: itemAct.itemId }, | ||||
|     data: { | ||||
|       boost: { increment: msatsToSats(itemAct.msats) } | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|   await tx.$executeRaw` | ||||
|     INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein) | ||||
|     VALUES ('expireBoost', jsonb_build_object('id', ${itemAct.itemId}::INTEGER), 21, true, | ||||
|               now() + interval '30 days', interval '40 days')` | ||||
| } | ||||
| 
 | ||||
| export async function onFail ({ invoice }, { tx }) { | ||||
|   await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) | ||||
| } | ||||
| 
 | ||||
| export async function describe ({ id: itemId, sats }, { actionId, cost }) { | ||||
|   return `SN: boost ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}` | ||||
| } | ||||
| @ -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' | ||||
|  | ||||
							
								
								
									
										63
									
								
								components/boost-button.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								components/boost-button.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,63 @@ | ||||
| import { useShowModal } from './modal' | ||||
| import { useToast } from './toast' | ||||
| import ItemAct from './item-act' | ||||
| import AccordianItem from './accordian-item' | ||||
| import { useMemo } from 'react' | ||||
| import getColor from '@/lib/rainbow' | ||||
| import UpBolt from '@/svgs/bolt.svg' | ||||
| import styles from './upvote.module.css' | ||||
| import { BoostHelp } from './adv-post-form' | ||||
| import { BOOST_MULT } from '@/lib/constants' | ||||
| import classNames from 'classnames' | ||||
| 
 | ||||
| export default function Boost ({ item, className, ...props }) { | ||||
|   const { boost } = item | ||||
|   const style = useMemo(() => (boost | ||||
|     ? { | ||||
|         fill: getColor(boost), | ||||
|         filter: `drop-shadow(0 0 6px ${getColor(boost)}90)`, | ||||
|         transform: 'scaleX(-1)' | ||||
|       } | ||||
|     : { | ||||
|         transform: 'scaleX(-1)' | ||||
|       }), [boost]) | ||||
|   return ( | ||||
|     <Booster | ||||
|       item={item} As={({ ...oprops }) => | ||||
|         <div className='upvoteParent'> | ||||
|           <div | ||||
|             className={styles.upvoteWrapper} | ||||
|           > | ||||
|             <UpBolt | ||||
|               {...props} {...oprops} style={style} | ||||
|               width={26} | ||||
|               height={26} | ||||
|               className={classNames(styles.upvote, className, boost && styles.voted)} | ||||
|             /> | ||||
|           </div> | ||||
|         </div>} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function Booster ({ item, As, children }) { | ||||
|   const toaster = useToast() | ||||
|   const showModal = useShowModal() | ||||
| 
 | ||||
|   return ( | ||||
|     <As | ||||
|       onClick={async () => { | ||||
|         try { | ||||
|           showModal(onClose => | ||||
|             <ItemAct onClose={onClose} item={item} act='BOOST' step={BOOST_MULT}> | ||||
|               <AccordianItem header='what is boost?' body={<BoostHelp />} /> | ||||
|             </ItemAct>) | ||||
|         } catch (error) { | ||||
|           toaster.danger('failed to boost item') | ||||
|         } | ||||
|       }} | ||||
|     > | ||||
|       {children} | ||||
|     </As> | ||||
|   ) | ||||
| } | ||||
| @ -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 }) | ||||
|   }) | ||||
| } | ||||
							
								
								
									
										27
									
								
								worker/expireBoost.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								worker/expireBoost.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | ||||
| import { Prisma } from '@prisma/client' | ||||
| 
 | ||||
| export async function expireBoost ({ data: { id }, models }) { | ||||
|   // reset boost 30 days after last boost
 | ||||
|   // run in serializable because we use an aggregate here
 | ||||
|   // and concurrent boosts could be double counted
 | ||||
|   // serialization errors will cause pgboss retries
 | ||||
|   await models.$transaction( | ||||
|     [ | ||||
|       models.$executeRaw` | ||||
|         WITH boost AS ( | ||||
|           SELECT sum(msats) FILTER (WHERE created_at <= now() - interval '30 days') as old_msats, | ||||
|                 sum(msats) FILTER (WHERE created_at > now() - interval '30 days') as cur_msats | ||||
|           FROM "ItemAct" | ||||
|           WHERE act = 'BOOST' | ||||
|           AND "itemId" = ${Number(id)}::INTEGER | ||||
|         ) | ||||
|         UPDATE "Item" | ||||
|         SET boost = COALESCE(boost.cur_msats, 0), "oldBoost" = COALESCE(boost.old_msats, 0) | ||||
|         FROM boost | ||||
|         WHERE "Item".id = ${Number(id)}::INTEGER` | ||||
|     ], | ||||
|     { | ||||
|       isolationLevel: Prisma.TransactionIsolationLevel.Serializable | ||||
|     } | ||||
|   ) | ||||
| } | ||||
| @ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user