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_BILLING from './territoryBilling' | ||||||
| import * as TERRITORY_UNARCHIVE from './territoryUnarchive' | import * as TERRITORY_UNARCHIVE from './territoryUnarchive' | ||||||
| import * as DONATE from './donate' | import * as DONATE from './donate' | ||||||
|  | import * as BOOST from './boost' | ||||||
| import wrapInvoice from 'wallets/wrap' | import wrapInvoice from 'wallets/wrap' | ||||||
| import { createInvoice as createUserInvoice } from 'wallets/server' | import { createInvoice as createUserInvoice } from 'wallets/server' | ||||||
| 
 | 
 | ||||||
| @ -21,6 +22,7 @@ export const paidActions = { | |||||||
|   ITEM_UPDATE, |   ITEM_UPDATE, | ||||||
|   ZAP, |   ZAP, | ||||||
|   DOWN_ZAP, |   DOWN_ZAP, | ||||||
|  |   BOOST, | ||||||
|   POLL_VOTE, |   POLL_VOTE, | ||||||
|   TERRITORY_CREATE, |   TERRITORY_CREATE, | ||||||
|   TERRITORY_UPDATE, |   TERRITORY_UPDATE, | ||||||
| @ -186,7 +188,12 @@ export async function retryPaidAction (actionType, args, context) { | |||||||
|   context.optimistic = true |   context.optimistic = true | ||||||
|   context.me = await models.user.findUnique({ where: { id: me.id } }) |   context.me = await models.user.findUnique({ where: { id: me.id } }) | ||||||
| 
 | 
 | ||||||
|   const { msatsRequested, actionId } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } }) |   const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } }) | ||||||
|  |   if (!failedInvoice) { | ||||||
|  |     throw new Error(`retryPaidAction - invoice not found or not in failed state ${actionType}`) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const { msatsRequested, actionId } = failedInvoice | ||||||
|   context.cost = BigInt(msatsRequested) |   context.cost = BigInt(msatsRequested) | ||||||
|   context.actionId = actionId |   context.actionId = actionId | ||||||
|   const invoiceArgs = await createSNInvoice(actionType, args, context) |   const invoiceArgs = await createSNInvoice(actionType, args, context) | ||||||
| @ -245,8 +252,8 @@ export async function createLightningInvoice (actionType, args, context) { | |||||||
|     try { |     try { | ||||||
|       const description = await paidActions[actionType].describe(args, context) |       const description = await paidActions[actionType].describe(args, context) | ||||||
|       const { invoice: bolt11, wallet } = await createUserInvoice(userId, { |       const { invoice: bolt11, wallet } = await createUserInvoice(userId, { | ||||||
|         // this is the amount the stacker will receive, the other 1/10th is the fee
 |         // this is the amount the stacker will receive, the other 3/10ths is the sybil fee
 | ||||||
|         msats: cost * BigInt(9) / BigInt(10), |         msats: cost * BigInt(7) / BigInt(10), | ||||||
|         description, |         description, | ||||||
|         expiry: INVOICE_EXPIRE_SECS |         expiry: INVOICE_EXPIRE_SECS | ||||||
|       }, { models }) |       }, { models }) | ||||||
|  | |||||||
| @ -195,6 +195,13 @@ export async function onPaid ({ invoice, id }, context) { | |||||||
|     INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) |     INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) | ||||||
|     VALUES ('imgproxy', jsonb_build_object('id', ${item.id}::INTEGER), 21, true, now() + interval '5 seconds')` |     VALUES ('imgproxy', jsonb_build_object('id', ${item.id}::INTEGER), 21, true, now() + interval '5 seconds')` | ||||||
| 
 | 
 | ||||||
|  |   if (item.boost > 0) { | ||||||
|  |     await tx.$executeRaw` | ||||||
|  |     INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein) | ||||||
|  |     VALUES ('expireBoost', jsonb_build_object('id', ${item.id}::INTEGER), 21, true, | ||||||
|  |               now() + interval '30 days', interval '40 days')` | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   if (item.parentId) { |   if (item.parentId) { | ||||||
|     // denormalize ncomments, lastCommentAt, and "weightedComments" for ancestors, and insert into reply table
 |     // denormalize ncomments, lastCommentAt, and "weightedComments" for ancestors, and insert into reply table
 | ||||||
|     await tx.$executeRaw` |     await tx.$executeRaw` | ||||||
|  | |||||||
| @ -13,7 +13,7 @@ export async function getCost ({ id, boost = 0, uploadIds }, { me, models }) { | |||||||
|   // or more boost
 |   // or more boost
 | ||||||
|   const old = await models.item.findUnique({ where: { id: parseInt(id) } }) |   const old = await models.item.findUnique({ where: { id: parseInt(id) } }) | ||||||
|   const { totalFeesMsats } = await uploadFees(uploadIds, { models, me }) |   const { totalFeesMsats } = await uploadFees(uploadIds, { models, me }) | ||||||
|   return BigInt(totalFeesMsats) + satsToMsats(boost - (old.boost || 0)) |   return BigInt(totalFeesMsats) + satsToMsats(boost - old.boost) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function perform (args, context) { | export async function perform (args, context) { | ||||||
| @ -30,9 +30,10 @@ export async function perform (args, context) { | |||||||
|     } |     } | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   const boostMsats = satsToMsats(boost - (old.boost || 0)) |   const newBoost = boost - old.boost | ||||||
|   const itemActs = [] |   const itemActs = [] | ||||||
|   if (boostMsats > 0) { |   if (newBoost > 0) { | ||||||
|  |     const boostMsats = satsToMsats(newBoost) | ||||||
|     itemActs.push({ |     itemActs.push({ | ||||||
|       msats: boostMsats, act: 'BOOST', userId: me?.id || USER_ID.anon |       msats: boostMsats, act: 'BOOST', userId: me?.id || USER_ID.anon | ||||||
|     }) |     }) | ||||||
| @ -54,15 +55,19 @@ export async function perform (args, context) { | |||||||
|     data: { paid: true } |     data: { paid: true } | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|  |   // we put boost in the where clause because we don't want to update the boost
 | ||||||
|  |   // if it has changed concurrently
 | ||||||
|   const item = await tx.item.update({ |   const item = await tx.item.update({ | ||||||
|     where: { id: parseInt(id) }, |     where: { id: parseInt(id), boost: old.boost }, | ||||||
|     include: { |     include: { | ||||||
|       mentions: true, |       mentions: true, | ||||||
|       itemReferrers: { include: { refereeItem: true } } |       itemReferrers: { include: { refereeItem: true } } | ||||||
|     }, |     }, | ||||||
|     data: { |     data: { | ||||||
|       ...data, |       ...data, | ||||||
|       boost, |       boost: { | ||||||
|  |         increment: newBoost | ||||||
|  |       }, | ||||||
|       pollOptions: { |       pollOptions: { | ||||||
|         createMany: { |         createMany: { | ||||||
|           data: pollOptions?.map(option => ({ option })) |           data: pollOptions?.map(option => ({ option })) | ||||||
| @ -126,8 +131,17 @@ export async function perform (args, context) { | |||||||
|     } |     } | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   await tx.$executeRaw`INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
 |   await tx.$executeRaw` | ||||||
|     VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true, now() + interval '5 seconds')` |     INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein) | ||||||
|  |     VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true, | ||||||
|  |               now() + interval '5 seconds', interval '1 day')` | ||||||
|  | 
 | ||||||
|  |   if (newBoost > 0) { | ||||||
|  |     await tx.$executeRaw` | ||||||
|  |       INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein) | ||||||
|  |       VALUES ('expireBoost', jsonb_build_object('id', ${id}::INTEGER), 21, true, | ||||||
|  |                 now() + interval '30 days', interval '40 days')` | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   await performBotBehavior(args, context) |   await performBotBehavior(args, context) | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -28,7 +28,7 @@ export async function invoiceablePeer ({ id }, { models }) { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) { | export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) { | ||||||
|   const feeMsats = cost / BigInt(10) // 10% fee
 |   const feeMsats = 3n * (cost / BigInt(10)) // 30% fee
 | ||||||
|   const zapMsats = cost - feeMsats |   const zapMsats = cost - feeMsats | ||||||
|   itemId = parseInt(itemId) |   itemId = parseInt(itemId) | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -6,8 +6,9 @@ import domino from 'domino' | |||||||
| import { | import { | ||||||
|   ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD, |   ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD, | ||||||
|   COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, |   COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, | ||||||
|   USER_ID, POLL_COST, |   USER_ID, POLL_COST, ADMIN_ITEMS, GLOBAL_SEED, | ||||||
|   ADMIN_ITEMS, GLOBAL_SEED, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS |   NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS, | ||||||
|  |   BOOST_MULT | ||||||
| } from '@/lib/constants' | } from '@/lib/constants' | ||||||
| import { msatsToSats } from '@/lib/format' | import { msatsToSats } from '@/lib/format' | ||||||
| import { parse } from 'tldts' | import { parse } from 'tldts' | ||||||
| @ -30,13 +31,13 @@ function commentsOrderByClause (me, models, sort) { | |||||||
|   if (me && sort === 'hot') { |   if (me && sort === 'hot') { | ||||||
|     return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, COALESCE(
 |     return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, COALESCE(
 | ||||||
|         personal_hot_score, |         personal_hot_score, | ||||||
|         ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST, |         ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST, | ||||||
|         "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC` |         "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC` | ||||||
|   } else { |   } else { | ||||||
|     if (sort === 'top') { |     if (sort === 'top') { | ||||||
|       return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator(models, 0)} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC,  "Item".id DESC` |       return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0 })} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC,  "Item".id DESC` | ||||||
|     } else { |     } else { | ||||||
|       return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC` |       return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC` | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @ -73,6 +74,29 @@ export async function getItem (parent, { id }, { me, models }) { | |||||||
|   return item |   return item | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { me, models }) { | ||||||
|  |   return (await itemQueryWithMeta({ | ||||||
|  |     me, | ||||||
|  |     models, | ||||||
|  |     query: ` | ||||||
|  |       ${SELECT} | ||||||
|  |       FROM "Item" | ||||||
|  |       LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" | ||||||
|  |       ${whereClause( | ||||||
|  |         '"parentId" IS NULL', | ||||||
|  |         '"Item"."pinId" IS NULL', | ||||||
|  |         '"Item"."deletedAt" IS NULL', | ||||||
|  |         '"Item"."parentId" IS NULL', | ||||||
|  |         '"Item".bio = false', | ||||||
|  |         '"Item".boost > 0', | ||||||
|  |         activeOrMine(), | ||||||
|  |         subClause(sub, 1, 'Item', me, showNsfw), | ||||||
|  |         muteClause(me))} | ||||||
|  |       ORDER BY boost desc, "Item".created_at ASC | ||||||
|  |       LIMIT 1` | ||||||
|  |   }, ...subArr))?.[0] || null | ||||||
|  | } | ||||||
|  | 
 | ||||||
| const orderByClause = (by, me, models, type) => { | const orderByClause = (by, me, models, type) => { | ||||||
|   switch (by) { |   switch (by) { | ||||||
|     case 'comments': |     case 'comments': | ||||||
| @ -88,12 +112,12 @@ const orderByClause = (by, me, models, type) => { | |||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function orderByNumerator (models, commentScaler = 0.5) { | export function orderByNumerator ({ models, commentScaler = 0.5, considerBoost = false }) { | ||||||
|   return `(CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0 THEN
 |   return `(CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0 THEN
 | ||||||
|               GREATEST("Item"."weightedVotes" - "Item"."weightedDownVotes", POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", 1.2)) |               GREATEST("Item"."weightedVotes" - "Item"."weightedDownVotes", POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", 1.2)) | ||||||
|             ELSE |             ELSE | ||||||
|               "Item"."weightedVotes" - "Item"."weightedDownVotes" |               "Item"."weightedVotes" - "Item"."weightedDownVotes" | ||||||
|             END + "Item"."weightedComments"*${commentScaler})` |             END + "Item"."weightedComments"*${commentScaler}) + ${considerBoost ? `("Item".boost / ${BOOST_MULT})` : 0}` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function joinZapRankPersonalView (me, models) { | export function joinZapRankPersonalView (me, models) { | ||||||
| @ -304,7 +328,7 @@ export default { | |||||||
|     }, |     }, | ||||||
|     items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit = LIMIT }, { me, models }) => { |     items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit = LIMIT }, { me, models }) => { | ||||||
|       const decodedCursor = decodeCursor(cursor) |       const decodedCursor = decodeCursor(cursor) | ||||||
|       let items, user, pins, subFull, table |       let items, user, pins, subFull, table, ad | ||||||
| 
 | 
 | ||||||
|       // special authorization for bookmarks depending on owning users' privacy settings
 |       // special authorization for bookmarks depending on owning users' privacy settings
 | ||||||
|       if (type === 'bookmarks' && name && me?.name !== name) { |       if (type === 'bookmarks' && name && me?.name !== name) { | ||||||
| @ -442,27 +466,54 @@ export default { | |||||||
|                 models, |                 models, | ||||||
|                 query: ` |                 query: ` | ||||||
|                   ${SELECT}, |                   ${SELECT}, | ||||||
|                     CASE WHEN status = 'ACTIVE' AND "maxBid" > 0 |                     (boost IS NOT NULL AND boost > 0)::INT AS group_rank, | ||||||
|                          THEN 0 ELSE 1 END AS group_rank, |                     CASE WHEN boost IS NOT NULL AND boost > 0 | ||||||
|                     CASE WHEN status = 'ACTIVE' AND "maxBid" > 0 |                          THEN rank() OVER (ORDER BY boost DESC, created_at ASC) | ||||||
|                          THEN rank() OVER (ORDER BY "maxBid" DESC, created_at ASC) |  | ||||||
|                          ELSE rank() OVER (ORDER BY created_at DESC) END AS rank |                          ELSE rank() OVER (ORDER BY created_at DESC) END AS rank | ||||||
|                     FROM "Item" |                     FROM "Item" | ||||||
|                     ${whereClause( |                     ${whereClause( | ||||||
|                       '"parentId" IS NULL', |                       '"parentId" IS NULL', | ||||||
|                       '"Item"."deletedAt" IS NULL', |                       '"Item"."deletedAt" IS NULL', | ||||||
|  |                       '"Item"."status" = \'ACTIVE\'', | ||||||
|                       'created_at <= $1', |                       'created_at <= $1', | ||||||
|                       '"pinId" IS NULL', |                       '"pinId" IS NULL', | ||||||
|                       subClause(sub, 4), |                       subClause(sub, 4) | ||||||
|                       "status IN ('ACTIVE', 'NOSATS')" |  | ||||||
|                     )} |                     )} | ||||||
|                     ORDER BY group_rank, rank |                     ORDER BY group_rank, rank | ||||||
|                   OFFSET $2 |                   OFFSET $2 | ||||||
|                   LIMIT $3`,
 |                   LIMIT $3`,
 | ||||||
|                 orderBy: 'ORDER BY group_rank, rank' |                 orderBy: 'ORDER BY group_rank DESC, rank' | ||||||
|               }, decodedCursor.time, decodedCursor.offset, limit, ...subArr) |               }, decodedCursor.time, decodedCursor.offset, limit, ...subArr) | ||||||
|               break |               break | ||||||
|             default: |             default: | ||||||
|  |               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({ |               items = await itemQueryWithMeta({ | ||||||
|                 me, |                 me, | ||||||
|                 models, |                 models, | ||||||
| @ -478,6 +529,7 @@ export default { | |||||||
|                       '"Item"."parentId" IS NULL', |                       '"Item"."parentId" IS NULL', | ||||||
|                       '"Item".outlawed = false', |                       '"Item".outlawed = false', | ||||||
|                       '"Item".bio = false', |                       '"Item".bio = false', | ||||||
|  |                       ad ? `"Item".id <> ${ad.id}` : '', | ||||||
|                       activeOrMine(me), |                       activeOrMine(me), | ||||||
|                       subClause(sub, 3, 'Item', me, showNsfw), |                       subClause(sub, 3, 'Item', me, showNsfw), | ||||||
|                       muteClause(me))} |                       muteClause(me))} | ||||||
| @ -487,8 +539,8 @@ export default { | |||||||
|                 orderBy: 'ORDER BY rank DESC' |                 orderBy: 'ORDER BY rank DESC' | ||||||
|               }, decodedCursor.offset, limit, ...subArr) |               }, decodedCursor.offset, limit, ...subArr) | ||||||
| 
 | 
 | ||||||
|               // XXX this is just for subs that are really empty
 |               // XXX this is mostly for subs that are really empty
 | ||||||
|               if (decodedCursor.offset === 0 && items.length < limit) { |               if (items.length < limit) { | ||||||
|                 items = await itemQueryWithMeta({ |                 items = await itemQueryWithMeta({ | ||||||
|                   me, |                   me, | ||||||
|                   models, |                   models, | ||||||
| @ -504,40 +556,17 @@ export default { | |||||||
|                         '"Item"."deletedAt" IS NULL', |                         '"Item"."deletedAt" IS NULL', | ||||||
|                         '"Item"."parentId" IS NULL', |                         '"Item"."parentId" IS NULL', | ||||||
|                         '"Item".bio = false', |                         '"Item".bio = false', | ||||||
|  |                         ad ? `"Item".id <> ${ad.id}` : '', | ||||||
|                         activeOrMine(me), |                         activeOrMine(me), | ||||||
|                         await filterClause(me, models, type))} |                         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 |                       OFFSET $1 | ||||||
|                       LIMIT $2`,
 |                       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) |                 }, 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 | ||||||
|           } |           } | ||||||
|           break |           break | ||||||
| @ -545,7 +574,8 @@ export default { | |||||||
|       return { |       return { | ||||||
|         cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null, |         cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null, | ||||||
|         items, |         items, | ||||||
|         pins |         pins, | ||||||
|  |         ad | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     item: getItem, |     item: getItem, | ||||||
| @ -615,18 +645,17 @@ export default { | |||||||
|           LIMIT 3` |           LIMIT 3` | ||||||
|       }, similar) |       }, similar) | ||||||
|     }, |     }, | ||||||
|     auctionPosition: async (parent, { id, sub, bid }, { models, me }) => { |     auctionPosition: async (parent, { id, sub, boost }, { models, me }) => { | ||||||
|       const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date() |       const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date() | ||||||
|       let where |       let where | ||||||
|       if (bid > 0) { |       if (boost > 0) { | ||||||
|         // if there's a bid
 |         // if there's boost
 | ||||||
|         // it's ACTIVE and has a larger bid than ours, or has an equal bid and is older
 |         // has a larger boost than ours, or has an equal boost and is older
 | ||||||
|         // count items: (bid > ours.bid OR (bid = ours.bid AND create_at < ours.created_at)) AND status = 'ACTIVE'
 |         // count items: (boost > ours.boost OR (boost = ours.boost AND create_at < ours.created_at))
 | ||||||
|         where = { |         where = { | ||||||
|           status: 'ACTIVE', |  | ||||||
|           OR: [ |           OR: [ | ||||||
|             { maxBid: { gt: bid } }, |             { boost: { gt: boost } }, | ||||||
|             { maxBid: bid, createdAt: { lt: createdAt } } |             { boost, createdAt: { lt: createdAt } } | ||||||
|           ] |           ] | ||||||
|         } |         } | ||||||
|       } else { |       } else { | ||||||
| @ -635,18 +664,42 @@ export default { | |||||||
|         // count items: ((bid > ours.bid AND status = 'ACTIVE') OR (created_at > ours.created_at AND status <> 'STOPPED'))
 |         // count items: ((bid > ours.bid AND status = 'ACTIVE') OR (created_at > ours.created_at AND status <> 'STOPPED'))
 | ||||||
|         where = { |         where = { | ||||||
|           OR: [ |           OR: [ | ||||||
|             { maxBid: { gt: 0 }, status: 'ACTIVE' }, |             { boost: { gt: 0 } }, | ||||||
|             { createdAt: { gt: createdAt }, status: { not: 'STOPPED' } } |             { createdAt: { gt: createdAt } } | ||||||
|           ] |           ] | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       where.subName = sub |       where.AND = { | ||||||
|  |         subName: sub, | ||||||
|  |         status: 'ACTIVE', | ||||||
|  |         deletedAt: null | ||||||
|  |       } | ||||||
|  |       if (id) { | ||||||
|  |         where.AND.id = { not: Number(id) } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       return await models.item.count({ where }) + 1 | ||||||
|  |     }, | ||||||
|  |     boostPosition: async (parent, { id, sub, boost }, { models, me }) => { | ||||||
|  |       if (boost <= 0) { | ||||||
|  |         throw new GqlInputError('boost must be greater than 0') | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       const where = { | ||||||
|  |         boost: { gte: boost }, | ||||||
|  |         status: 'ACTIVE', | ||||||
|  |         deletedAt: null, | ||||||
|  |         outlawed: false | ||||||
|  |       } | ||||||
|       if (id) { |       if (id) { | ||||||
|         where.id = { not: Number(id) } |         where.id = { not: Number(id) } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       return await models.item.count({ where }) + 1 |       return { | ||||||
|  |         home: await models.item.count({ where }) === 0, | ||||||
|  |         sub: await models.item.count({ where: { ...where, subName: sub } }) === 0 | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
| @ -825,7 +878,6 @@ export default { | |||||||
|         item.uploadId = item.logo |         item.uploadId = item.logo | ||||||
|         delete item.logo |         delete item.logo | ||||||
|       } |       } | ||||||
|       item.maxBid ??= 0 |  | ||||||
| 
 | 
 | ||||||
|       if (id) { |       if (id) { | ||||||
|         return await updateItem(parent, { id, ...item }, { me, models, lnd }) |         return await updateItem(parent, { id, ...item }, { me, models, lnd }) | ||||||
| @ -862,7 +914,7 @@ export default { | |||||||
| 
 | 
 | ||||||
|       return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd }) |       return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd }) | ||||||
|     }, |     }, | ||||||
|     act: async (parent, { id, sats, act = 'TIP', idempotent }, { me, models, lnd, headers }) => { |     act: async (parent, { id, sats, act = 'TIP' }, { me, models, lnd, headers }) => { | ||||||
|       assertApiKeyNotPermitted({ me }) |       assertApiKeyNotPermitted({ me }) | ||||||
|       await ssValidate(actSchema, { sats, act }) |       await ssValidate(actSchema, { sats, act }) | ||||||
|       await assertGofacYourself({ models, headers }) |       await assertGofacYourself({ models, headers }) | ||||||
| @ -881,7 +933,7 @@ export default { | |||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       // disallow self tips except anons
 |       // disallow self tips except anons
 | ||||||
|       if (me) { |       if (me && ['TIP', 'DONT_LIKE_THIS'].includes(act)) { | ||||||
|         if (Number(item.userId) === Number(me.id)) { |         if (Number(item.userId) === Number(me.id)) { | ||||||
|           throw new GqlInputError('cannot zap yourself') |           throw new GqlInputError('cannot zap yourself') | ||||||
|         } |         } | ||||||
| @ -899,6 +951,8 @@ export default { | |||||||
|         return await performPaidAction('ZAP', { id, sats }, { me, models, lnd }) |         return await performPaidAction('ZAP', { id, sats }, { me, models, lnd }) | ||||||
|       } else if (act === 'DONT_LIKE_THIS') { |       } else if (act === 'DONT_LIKE_THIS') { | ||||||
|         return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd }) |         return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd }) | ||||||
|  |       } else if (act === 'BOOST') { | ||||||
|  |         return await performPaidAction('BOOST', { id, sats }, { me, models, lnd }) | ||||||
|       } else { |       } else { | ||||||
|         throw new GqlInputError('unknown act') |         throw new GqlInputError('unknown act') | ||||||
|       } |       } | ||||||
| @ -1321,7 +1375,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. | |||||||
|     item = { id: Number(item.id), text: item.text, title: `@${user.name}'s bio` } |     item = { id: Number(item.id), text: item.text, title: `@${user.name}'s bio` } | ||||||
|   } else if (old.parentId) { |   } else if (old.parentId) { | ||||||
|     // prevent editing a comment like a post
 |     // prevent editing a comment like a post
 | ||||||
|     item = { id: Number(item.id), text: item.text } |     item = { id: Number(item.id), text: item.text, boost: item.boost } | ||||||
|   } else { |   } else { | ||||||
|     item = { subName, ...item } |     item = { subName, ...item } | ||||||
|     item.forwardUsers = await getForwardUsers(models, forward) |     item.forwardUsers = await getForwardUsers(models, forward) | ||||||
| @ -1390,5 +1444,5 @@ export const SELECT = | |||||||
|     ltree2text("Item"."path") AS "path"` |     ltree2text("Item"."path") AS "path"` | ||||||
| 
 | 
 | ||||||
| function topOrderByWeightedSats (me, models) { | function topOrderByWeightedSats (me, models) { | ||||||
|   return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC` |   return `ORDER BY ${orderByNumerator({ models })} DESC NULLS LAST, "Item".id DESC` | ||||||
| } | } | ||||||
|  | |||||||
| @ -179,17 +179,6 @@ export default { | |||||||
|         )` |         )` | ||||||
|       ) |       ) | ||||||
| 
 | 
 | ||||||
|       queries.push( |  | ||||||
|         `(SELECT "Item".id::text, "Item"."statusUpdatedAt" AS "sortTime", NULL as "earnedSats",
 |  | ||||||
|           'JobChanged' AS type |  | ||||||
|           FROM "Item" |  | ||||||
|           WHERE "Item"."userId" = $1 |  | ||||||
|           AND "maxBid" IS NOT NULL |  | ||||||
|           AND "statusUpdatedAt" < $2 AND "statusUpdatedAt" <> created_at |  | ||||||
|           ORDER BY "sortTime" DESC |  | ||||||
|           LIMIT ${LIMIT})` |  | ||||||
|       ) |  | ||||||
| 
 |  | ||||||
|       // territory transfers
 |       // territory transfers
 | ||||||
|       queries.push( |       queries.push( | ||||||
|         `(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL as "earnedSats",
 |         `(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL as "earnedSats",
 | ||||||
| @ -354,7 +343,8 @@ export default { | |||||||
|           "Invoice"."actionType" = 'ITEM_CREATE' OR |           "Invoice"."actionType" = 'ITEM_CREATE' OR | ||||||
|           "Invoice"."actionType" = 'ZAP' OR |           "Invoice"."actionType" = 'ZAP' OR | ||||||
|           "Invoice"."actionType" = 'DOWN_ZAP' OR |           "Invoice"."actionType" = 'DOWN_ZAP' OR | ||||||
|           "Invoice"."actionType" = 'POLL_VOTE' |           "Invoice"."actionType" = 'POLL_VOTE' OR | ||||||
|  |           "Invoice"."actionType" = 'BOOST' | ||||||
|         ) |         ) | ||||||
|         ORDER BY "sortTime" DESC |         ORDER BY "sortTime" DESC | ||||||
|         LIMIT ${LIMIT})` |         LIMIT ${LIMIT})` | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ function paidActionType (actionType) { | |||||||
|       return 'ItemPaidAction' |       return 'ItemPaidAction' | ||||||
|     case 'ZAP': |     case 'ZAP': | ||||||
|     case 'DOWN_ZAP': |     case 'DOWN_ZAP': | ||||||
|  |     case 'BOOST': | ||||||
|       return 'ItemActPaidAction' |       return 'ItemActPaidAction' | ||||||
|     case 'TERRITORY_CREATE': |     case 'TERRITORY_CREATE': | ||||||
|     case 'TERRITORY_UPDATE': |     case 'TERRITORY_UPDATE': | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| import { amountSchema, ssValidate } from '@/lib/validate' | import { amountSchema, ssValidate } from '@/lib/validate' | ||||||
| import { getItem } from './item' | import { getAd, getItem } from './item' | ||||||
| import { topUsers } from './user' | import { topUsers } from './user' | ||||||
| import performPaidAction from '../paidAction' | import performPaidAction from '../paidAction' | ||||||
| import { GqlInputError } from '@/lib/error' | import { GqlInputError } from '@/lib/error' | ||||||
| @ -164,6 +164,9 @@ export default { | |||||||
|         return 0 |         return 0 | ||||||
|       } |       } | ||||||
|       return parent.total |       return parent.total | ||||||
|  |     }, | ||||||
|  |     ad: async (parent, args, { me, models }) => { | ||||||
|  |       return await getAd(parent, { }, { me, models }) | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   Mutation: { |   Mutation: { | ||||||
|  | |||||||
| @ -396,22 +396,6 @@ export default { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       const job = await models.item.findFirst({ |  | ||||||
|         where: { |  | ||||||
|           maxBid: { |  | ||||||
|             not: null |  | ||||||
|           }, |  | ||||||
|           userId: me.id, |  | ||||||
|           statusUpdatedAt: { |  | ||||||
|             gt: lastChecked |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       }) |  | ||||||
|       if (job && job.statusUpdatedAt > job.createdAt) { |  | ||||||
|         foundNotes() |  | ||||||
|         return true |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       if (user.noteEarning) { |       if (user.noteEarning) { | ||||||
|         const earn = await models.earn.findFirst({ |         const earn = await models.earn.findFirst({ | ||||||
|           where: { |           where: { | ||||||
|  | |||||||
| @ -516,6 +516,7 @@ const resolvers = { | |||||||
|         case 'ZAP': |         case 'ZAP': | ||||||
|         case 'DOWN_ZAP': |         case 'DOWN_ZAP': | ||||||
|         case 'POLL_VOTE': |         case 'POLL_VOTE': | ||||||
|  |         case 'BOOST': | ||||||
|           return (await itemQueryWithMeta({ |           return (await itemQueryWithMeta({ | ||||||
|             me, |             me, | ||||||
|             models, |             models, | ||||||
| @ -532,12 +533,14 @@ const resolvers = { | |||||||
|       const action2act = { |       const action2act = { | ||||||
|         ZAP: 'TIP', |         ZAP: 'TIP', | ||||||
|         DOWN_ZAP: 'DONT_LIKE_THIS', |         DOWN_ZAP: 'DONT_LIKE_THIS', | ||||||
|         POLL_VOTE: 'POLL' |         POLL_VOTE: 'POLL', | ||||||
|  |         BOOST: 'BOOST' | ||||||
|       } |       } | ||||||
|       switch (invoice.actionType) { |       switch (invoice.actionType) { | ||||||
|         case 'ZAP': |         case 'ZAP': | ||||||
|         case 'DOWN_ZAP': |         case 'DOWN_ZAP': | ||||||
|         case 'POLL_VOTE': |         case 'POLL_VOTE': | ||||||
|  |         case 'BOOST': | ||||||
|           return (await models.$queryRaw` |           return (await models.$queryRaw` | ||||||
|               SELECT id, act, "invoiceId", "invoiceActionState", msats |               SELECT id, act, "invoiceId", "invoiceActionState", msats | ||||||
|               FROM "ItemAct" |               FROM "ItemAct" | ||||||
|  | |||||||
| @ -8,10 +8,16 @@ export default gql` | |||||||
|     dupes(url: String!): [Item!] |     dupes(url: String!): [Item!] | ||||||
|     related(cursor: String, title: String, id: ID, minMatch: String, limit: Limit): Items |     related(cursor: String, title: String, id: ID, minMatch: String, limit: Limit): Items | ||||||
|     search(q: String, sub: String, cursor: String, what: String, sort: String, when: String, from: String, to: String): Items |     search(q: String, sub: String, cursor: String, what: String, sort: String, when: String, from: String, to: String): Items | ||||||
|     auctionPosition(sub: String, id: ID, bid: Int!): Int! |     auctionPosition(sub: String, id: ID, boost: Int): Int! | ||||||
|  |     boostPosition(sub: String, id: ID, boost: Int): BoostPositions! | ||||||
|     itemRepetition(parentId: ID): Int! |     itemRepetition(parentId: ID): Int! | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   type BoostPositions { | ||||||
|  |     home: Boolean! | ||||||
|  |     sub: Boolean! | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   type TitleUnshorted { |   type TitleUnshorted { | ||||||
|     title: String |     title: String | ||||||
|     unshorted: String |     unshorted: String | ||||||
| @ -46,12 +52,12 @@ export default gql` | |||||||
|       hash: String, hmac: String): ItemPaidAction! |       hash: String, hmac: String): ItemPaidAction! | ||||||
|     upsertJob( |     upsertJob( | ||||||
|       id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, |       id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, | ||||||
|       text: String!, url: String!, maxBid: Int!, status: String, logo: Int): ItemPaidAction! |       text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction! | ||||||
|     upsertPoll( |     upsertPoll( | ||||||
|       id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date, |       id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date, | ||||||
|       hash: String, hmac: String): ItemPaidAction! |       hash: String, hmac: String): ItemPaidAction! | ||||||
|     updateNoteId(id: ID!, noteId: String!): Item! |     updateNoteId(id: ID!, noteId: String!): Item! | ||||||
|     upsertComment(id: ID, text: String!, parentId: ID, hash: String, hmac: String): ItemPaidAction! |     upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction! | ||||||
|     act(id: ID!, sats: Int, act: String, idempotent: Boolean): ItemActPaidAction! |     act(id: ID!, sats: Int, act: String, idempotent: Boolean): ItemActPaidAction! | ||||||
|     pollVote(id: ID!): PollVotePaidAction! |     pollVote(id: ID!): PollVotePaidAction! | ||||||
|     toggleOutlaw(id: ID!): Item! |     toggleOutlaw(id: ID!): Item! | ||||||
| @ -79,6 +85,7 @@ export default gql` | |||||||
|     cursor: String |     cursor: String | ||||||
|     items: [Item!]! |     items: [Item!]! | ||||||
|     pins: [Item!] |     pins: [Item!] | ||||||
|  |     ad: Item | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   type Comments { |   type Comments { | ||||||
| @ -136,7 +143,6 @@ export default gql` | |||||||
|     path: String |     path: String | ||||||
|     position: Int |     position: Int | ||||||
|     prior: Int |     prior: Int | ||||||
|     maxBid: Int |  | ||||||
|     isJob: Boolean! |     isJob: Boolean! | ||||||
|     pollCost: Int |     pollCost: Int | ||||||
|     poll: Poll |     poll: Poll | ||||||
|  | |||||||
| @ -19,6 +19,7 @@ export default gql` | |||||||
|     time: Date! |     time: Date! | ||||||
|     sources: [NameValue!]! |     sources: [NameValue!]! | ||||||
|     leaderboard: UsersNullable |     leaderboard: UsersNullable | ||||||
|  |     ad: Item | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   type Reward { |   type Reward { | ||||||
|  | |||||||
| @ -127,3 +127,4 @@ brugeman,issue,#1311,#864,medium,high,,,50k,brugeman@stacker.news,2024-08-27 | |||||||
| riccardobl,pr,#1342,#1141,hard,high,,pending unrelated rearchitecture,1m,rblb@getalby.com,2024-09-09 | riccardobl,pr,#1342,#1141,hard,high,,pending unrelated rearchitecture,1m,rblb@getalby.com,2024-09-09 | ||||||
| SatsAllDay,issue,#1368,#1331,medium,,,,25k,weareallsatoshi@getalby.com,2024-09-16 | SatsAllDay,issue,#1368,#1331,medium,,,,25k,weareallsatoshi@getalby.com,2024-09-16 | ||||||
| benalleng,helpfulness,#1368,#1170,medium,,,did a lot of it in #1175,25k,BenAllenG@stacker.news,2024-09-16 | benalleng,helpfulness,#1368,#1170,medium,,,did a lot of it in #1175,25k,BenAllenG@stacker.news,2024-09-16 | ||||||
|  | humble-GOAT,issue,#1412,#1407,good-first-issue,,,,2k,humble_GOAT@stacker.news,2024-09-18 | ||||||
|  | |||||||
| 
 | 
| @ -1,4 +1,4 @@ | |||||||
| import { useState, useEffect } from 'react' | import { useState, useEffect, useMemo } from 'react' | ||||||
| import AccordianItem from './accordian-item' | import AccordianItem from './accordian-item' | ||||||
| import { Input, InputUserSuggest, VariableInput, Checkbox } from './form' | import { Input, InputUserSuggest, VariableInput, Checkbox } from './form' | ||||||
| import InputGroup from 'react-bootstrap/InputGroup' | import InputGroup from 'react-bootstrap/InputGroup' | ||||||
| @ -11,6 +11,8 @@ import { useMe } from './me' | |||||||
| import { useFeeButton } from './fee-button' | import { useFeeButton } from './fee-button' | ||||||
| import { useRouter } from 'next/router' | import { useRouter } from 'next/router' | ||||||
| import { useFormikContext } from 'formik' | import { useFormikContext } from 'formik' | ||||||
|  | import { gql, useLazyQuery } from '@apollo/client' | ||||||
|  | import useDebounceCallback from './use-debounce-callback' | ||||||
| 
 | 
 | ||||||
| const EMPTY_FORWARD = { nym: '', pct: '' } | const EMPTY_FORWARD = { nym: '', pct: '' } | ||||||
| 
 | 
 | ||||||
| @ -26,9 +28,118 @@ const FormStatus = { | |||||||
|   ERROR: 'error' |   ERROR: 'error' | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export default function AdvPostForm ({ children, item, storageKeyPrefix }) { | export function BoostHelp () { | ||||||
|  |   return ( | ||||||
|  |     <ol style={{ lineHeight: 1.25 }}> | ||||||
|  |       <li>Boost ranks items higher based on the amount</li> | ||||||
|  |       <li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li> | ||||||
|  |       <li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li> | ||||||
|  |       <li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li> | ||||||
|  |       <li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker | ||||||
|  |         <ul> | ||||||
|  |           <li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li> | ||||||
|  |         </ul> | ||||||
|  |       </li> | ||||||
|  |       <li>The decay of boost "votes" increases at 1.25x the rate of organic votes | ||||||
|  |         <ul> | ||||||
|  |           <li>i.e. boost votes fall out of ranking faster</li> | ||||||
|  |         </ul> | ||||||
|  |       </li> | ||||||
|  |       <li>boost can take a few minutes to show higher ranking in feed</li> | ||||||
|  |       <li>100% of boost goes to the territory founder and top stackers as rewards</li> | ||||||
|  |     </ol> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function BoostInput ({ onChange, ...props }) { | ||||||
|  |   const feeButton = useFeeButton() | ||||||
|  |   let merge | ||||||
|  |   if (feeButton) { | ||||||
|  |     ({ merge } = feeButton) | ||||||
|  |   } | ||||||
|  |   return ( | ||||||
|  |     <Input | ||||||
|  |       label={ | ||||||
|  |         <div className='d-flex align-items-center'>boost | ||||||
|  |           <Info> | ||||||
|  |             <BoostHelp /> | ||||||
|  |           </Info> | ||||||
|  |         </div> | ||||||
|  |     } | ||||||
|  |       name='boost' | ||||||
|  |       onChange={(_, e) => { | ||||||
|  |         merge?.({ | ||||||
|  |           boost: { | ||||||
|  |             term: `+ ${e.target.value}`, | ||||||
|  |             label: 'boost', | ||||||
|  |             op: '+', | ||||||
|  |             modifier: cost => cost + Number(e.target.value) | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |         onChange && onChange(_, e) | ||||||
|  |       }} | ||||||
|  |       hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>} | ||||||
|  |       append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // act means we are adding to existing boost
 | ||||||
|  | export function BoostItemInput ({ item, sub, act = false, ...props }) { | ||||||
|  |   const [boost, setBoost] = useState(Number(item?.boost) + (act ? BOOST_MULT : 0)) | ||||||
|  | 
 | ||||||
|  |   const [getBoostPosition, { data }] = useLazyQuery(gql` | ||||||
|  |     query BoostPosition($id: ID, $boost: Int) { | ||||||
|  |       boostPosition(sub: "${item?.subName || sub?.name}", id: $id, boost: $boost) { | ||||||
|  |         home | ||||||
|  |         sub | ||||||
|  |       } | ||||||
|  |     }`,
 | ||||||
|  |   { fetchPolicy: 'cache-and-network' }) | ||||||
|  | 
 | ||||||
|  |   const getPositionDebounce = useDebounceCallback((...args) => getBoostPosition(...args), 1000, [getBoostPosition]) | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (boost) { | ||||||
|  |       getPositionDebounce({ variables: { boost: Number(boost), id: item?.id } }) | ||||||
|  |     } | ||||||
|  |   }, [boost, item?.id]) | ||||||
|  | 
 | ||||||
|  |   const boostMessage = useMemo(() => { | ||||||
|  |     if (!item?.parentId) { | ||||||
|  |       if (data?.boostPosition?.home || data?.boostPosition?.sub) { | ||||||
|  |         const boostPinning = [] | ||||||
|  |         if (data?.boostPosition?.home) { | ||||||
|  |           boostPinning.push('homepage') | ||||||
|  |         } | ||||||
|  |         if (data?.boostPosition?.sub) { | ||||||
|  |           boostPinning.push(`~${item?.subName || sub?.name}`) | ||||||
|  |         } | ||||||
|  |         return `pins to the top of ${boostPinning.join(' and ')}` | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (boost >= 0 && boost % BOOST_MULT === 0) { | ||||||
|  |       return `${act ? 'brings to' : 'equivalent to'} ${numWithUnits(boost / BOOST_MULT, { unitPlural: 'zapvotes', unitSingular: 'zapvote' })}` | ||||||
|  |     } | ||||||
|  |     return 'ranks posts higher based on the amount' | ||||||
|  |   }, [boost, data?.boostPosition?.home, data?.boostPosition?.sub, item?.subName, sub?.name]) | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <BoostInput | ||||||
|  |       hint={<span className='text-muted'>{boostMessage}</span>} | ||||||
|  |       onChange={(_, e) => { | ||||||
|  |         if (e.target.value >= 0) { | ||||||
|  |           setBoost(Number(e.target.value) + (act ? Number(item?.boost) : 0)) | ||||||
|  |         } | ||||||
|  |       }} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default function AdvPostForm ({ children, item, sub, storageKeyPrefix }) { | ||||||
|   const { me } = useMe() |   const { me } = useMe() | ||||||
|   const { merge } = useFeeButton() |  | ||||||
|   const router = useRouter() |   const router = useRouter() | ||||||
|   const [itemType, setItemType] = useState() |   const [itemType, setItemType] = useState() | ||||||
|   const formik = useFormikContext() |   const formik = useFormikContext() | ||||||
| @ -111,39 +222,7 @@ export default function AdvPostForm ({ children, item, storageKeyPrefix }) { | |||||||
|       body={ |       body={ | ||||||
|         <> |         <> | ||||||
|           {children} |           {children} | ||||||
|           <Input |           <BoostItemInput item={item} sub={sub} /> | ||||||
|             label={ |  | ||||||
|               <div className='d-flex align-items-center'>boost |  | ||||||
|                 <Info> |  | ||||||
|                   <ol> |  | ||||||
|                     <li>Boost ranks posts higher temporarily based on the amount</li> |  | ||||||
|                     <li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li> |  | ||||||
|                     <li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to one trusted upvote |  | ||||||
|                       <ul> |  | ||||||
|                         <li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like 5 votes</li> |  | ||||||
|                       </ul> |  | ||||||
|                     </li> |  | ||||||
|                     <li>The decay of boost "votes" increases at 1.25x the rate of organic votes |  | ||||||
|                       <ul> |  | ||||||
|                         <li>i.e. boost votes fall out of ranking faster</li> |  | ||||||
|                       </ul> |  | ||||||
|                     </li> |  | ||||||
|                     <li>100% of sats from boost are given back to top stackers as rewards</li> |  | ||||||
|                   </ol> |  | ||||||
|                 </Info> |  | ||||||
|               </div> |  | ||||||
|             } |  | ||||||
|             name='boost' |  | ||||||
|             onChange={(_, e) => merge({ |  | ||||||
|               boost: { |  | ||||||
|                 term: `+ ${e.target.value}`, |  | ||||||
|                 label: 'boost', |  | ||||||
|                 modifier: cost => cost + Number(e.target.value) |  | ||||||
|               } |  | ||||||
|             })} |  | ||||||
|             hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>} |  | ||||||
|             append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} |  | ||||||
|           /> |  | ||||||
|           <VariableInput |           <VariableInput | ||||||
|             label='forward sats to' |             label='forward sats to' | ||||||
|             name='forward' |             name='forward' | ||||||
|  | |||||||
							
								
								
									
										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 |             : null | ||||||
|         } |         } | ||||||
|       /> |       /> | ||||||
|       <AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} /> |       <AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub} /> | ||||||
|       <ItemButtonBar itemId={item?.id} canDelete={false} /> |       <ItemButtonBar itemId={item?.id} canDelete={false} /> | ||||||
|     </Form> |     </Form> | ||||||
|   ) |   ) | ||||||
|  | |||||||
| @ -25,6 +25,7 @@ import Skull from '@/svgs/death-skull.svg' | |||||||
| import { commentSubTreeRootId } from '@/lib/item' | import { commentSubTreeRootId } from '@/lib/item' | ||||||
| import Pin from '@/svgs/pushpin-fill.svg' | import Pin from '@/svgs/pushpin-fill.svg' | ||||||
| import LinkToContext from './link-to-context' | import LinkToContext from './link-to-context' | ||||||
|  | import Boost from './boost-button' | ||||||
| 
 | 
 | ||||||
| function Parent ({ item, rootText }) { | function Parent ({ item, rootText }) { | ||||||
|   const root = useRoot() |   const root = useRoot() | ||||||
| @ -144,9 +145,11 @@ export default function Comment ({ | |||||||
|       <div className={`${itemStyles.item} ${styles.item}`}> |       <div className={`${itemStyles.item} ${styles.item}`}> | ||||||
|         {item.outlawed && !me?.privates?.wildWestMode |         {item.outlawed && !me?.privates?.wildWestMode | ||||||
|           ? <Skull className={styles.dontLike} width={24} height={24} /> |           ? <Skull className={styles.dontLike} width={24} height={24} /> | ||||||
|           : item.meDontLikeSats > item.meSats |           : item.mine | ||||||
|             ? <DownZap width={24} height={24} className={styles.dontLike} item={item} /> |             ? <Boost item={item} className={styles.upvote} /> | ||||||
|             : pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote 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={`${itemStyles.hunk} ${styles.hunk}`}> | ||||||
|           <div className='d-flex align-items-center'> |           <div className='d-flex align-items-center'> | ||||||
|             {item.user?.meMute && !includeParent && collapse === 'yep' |             {item.user?.meMute && !includeParent && collapse === 'yep' | ||||||
|  | |||||||
| @ -79,7 +79,7 @@ export function DiscussionForm ({ | |||||||
|           ? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div> |           ? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div> | ||||||
|           : null} |           : null} | ||||||
|       /> |       /> | ||||||
|       <AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} /> |       <AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub} /> | ||||||
|       <ItemButtonBar itemId={item?.id} /> |       <ItemButtonBar itemId={item?.id} /> | ||||||
|       {!item && |       {!item && | ||||||
|         <div className={`mt-3 ${related.length > 0 ? '' : 'invisible'}`}> |         <div className={`mt-3 ${related.length > 0 ? '' : 'invisible'}`}> | ||||||
|  | |||||||
| @ -17,7 +17,12 @@ export function DownZap ({ item, ...props }) { | |||||||
|       } |       } | ||||||
|     : undefined), [meDontLikeSats]) |     : undefined), [meDontLikeSats]) | ||||||
|   return ( |   return ( | ||||||
|     <DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} /> |     <DownZapper | ||||||
|  |       item={item} As={({ ...oprops }) => | ||||||
|  |         <div className='upvoteParent'> | ||||||
|  |           <Flag {...props} {...oprops} style={style} /> | ||||||
|  |         </div>} | ||||||
|  |     /> | ||||||
|   ) |   ) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -31,7 +36,7 @@ function DownZapper ({ item, As, children }) { | |||||||
|         try { |         try { | ||||||
|           showModal(onClose => |           showModal(onClose => | ||||||
|             <ItemAct |             <ItemAct | ||||||
|               onClose={onClose} item={item} down |               onClose={onClose} item={item} act='DONT_LIKE_THIS' | ||||||
|             > |             > | ||||||
|               <AccordianItem |               <AccordianItem | ||||||
|                 header='what is a downzap?' body={ |                 header='what is a downzap?' body={ | ||||||
|  | |||||||
| @ -21,6 +21,7 @@ export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me }) | |||||||
|         anonCharge: { |         anonCharge: { | ||||||
|           term: `x ${ANON_FEE_MULTIPLIER}`, |           term: `x ${ANON_FEE_MULTIPLIER}`, | ||||||
|           label: 'anon mult', |           label: 'anon mult', | ||||||
|  |           op: '*', | ||||||
|           modifier: (cost) => cost * ANON_FEE_MULTIPLIER |           modifier: (cost) => cost * ANON_FEE_MULTIPLIER | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| @ -28,6 +29,7 @@ export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me }) | |||||||
|     baseCost: { |     baseCost: { | ||||||
|       term: baseCost, |       term: baseCost, | ||||||
|       label: `${comment ? 'comment' : 'post'} cost`, |       label: `${comment ? 'comment' : 'post'} cost`, | ||||||
|  |       op: '_', | ||||||
|       modifier: (cost) => cost + baseCost, |       modifier: (cost) => cost + baseCost, | ||||||
|       allowFreebies: comment |       allowFreebies: comment | ||||||
|     }, |     }, | ||||||
| @ -48,10 +50,12 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) { | |||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|       const repetition = data?.itemRepetition |       const repetition = data?.itemRepetition | ||||||
|       if (!repetition) return setLine({}) |       if (!repetition) return setLine({}) | ||||||
|  |       console.log('repetition', repetition) | ||||||
|       setLine({ |       setLine({ | ||||||
|         itemRepetition: { |         itemRepetition: { | ||||||
|           term: <>x 10<sup>{repetition}</sup></>, |           term: <>x 10<sup>{repetition}</sup></>, | ||||||
|           label: <>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</>, |           label: <>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</>, | ||||||
|  |           op: '*', | ||||||
|           modifier: (cost) => cost * Math.pow(10, repetition) |           modifier: (cost) => cost * Math.pow(10, repetition) | ||||||
|         } |         } | ||||||
|       }) |       }) | ||||||
| @ -61,6 +65,35 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) { | |||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | function sortHelper (a, b) { | ||||||
|  |   if (a.op === '_') { | ||||||
|  |     return -1 | ||||||
|  |   } else if (b.op === '_') { | ||||||
|  |     return 1 | ||||||
|  |   } else if (a.op === '*' || a.op === '/') { | ||||||
|  |     if (b.op === '*' || b.op === '/') { | ||||||
|  |       return 0 | ||||||
|  |     } | ||||||
|  |     // a is higher precedence
 | ||||||
|  |     return -1 | ||||||
|  |   } else { | ||||||
|  |     if (b.op === '*' || b.op === '/') { | ||||||
|  |       // b is higher precedence
 | ||||||
|  |       return 1 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // postive first
 | ||||||
|  |     if (a.op === '+' && b.op === '-') { | ||||||
|  |       return -1 | ||||||
|  |     } | ||||||
|  |     if (a.op === '-' && b.op === '+') { | ||||||
|  |       return 1 | ||||||
|  |     } | ||||||
|  |     // both are + or -
 | ||||||
|  |     return 0 | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) { | export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) { | ||||||
|   const [lineItems, setLineItems] = useState({}) |   const [lineItems, setLineItems] = useState({}) | ||||||
|   const [disabled, setDisabled] = useState(false) |   const [disabled, setDisabled] = useState(false) | ||||||
| @ -77,7 +110,7 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () | |||||||
| 
 | 
 | ||||||
|   const value = useMemo(() => { |   const value = useMemo(() => { | ||||||
|     const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems } |     const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems } | ||||||
|     const total = Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0) |     const total = Object.values(lines).sort(sortHelper).reduce((acc, { modifier }) => modifier(acc), 0) | ||||||
|     // freebies: there's only a base cost and we don't have enough sats
 |     // freebies: there's only a base cost and we don't have enough sats
 | ||||||
|     const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total && !me?.privates?.disableFreebies |     const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total && !me?.privates?.disableFreebies | ||||||
|     return { |     return { | ||||||
| @ -145,7 +178,7 @@ function Receipt ({ lines, total }) { | |||||||
|   return ( |   return ( | ||||||
|     <Table className={styles.receipt} borderless size='sm'> |     <Table className={styles.receipt} borderless size='sm'> | ||||||
|       <tbody> |       <tbody> | ||||||
|         {Object.entries(lines).map(([key, { term, label, omit }]) => ( |         {Object.entries(lines).sort(([, a], [, b]) => sortHelper(a, b)).map(([key, { term, label, omit }]) => ( | ||||||
|           !omit && |           !omit && | ||||||
|             <tr key={key}> |             <tr key={key}> | ||||||
|               <td>{term}</td> |               <td>{term}</td> | ||||||
|  | |||||||
| @ -42,7 +42,7 @@ export class SessionRequiredError extends Error { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function SubmitButton ({ | export function SubmitButton ({ | ||||||
|   children, variant, value, onClick, disabled, appendText, submittingText, |   children, variant, valueName = 'submit', value, onClick, disabled, appendText, submittingText, | ||||||
|   className, ...props |   className, ...props | ||||||
| }) { | }) { | ||||||
|   const formik = useFormikContext() |   const formik = useFormikContext() | ||||||
| @ -58,7 +58,7 @@ export function SubmitButton ({ | |||||||
|       disabled={disabled} |       disabled={disabled} | ||||||
|       onClick={value |       onClick={value | ||||||
|         ? e => { |         ? e => { | ||||||
|           formik.setFieldValue('submit', value) |           formik.setFieldValue(valueName, value) | ||||||
|           onClick && onClick(e) |           onClick && onClick(e) | ||||||
|         } |         } | ||||||
|         : onClick} |         : onClick} | ||||||
| @ -141,6 +141,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe | |||||||
|         uploadFees: { |         uploadFees: { | ||||||
|           term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`, |           term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`, | ||||||
|           label: 'upload fee', |           label: 'upload fee', | ||||||
|  |           op: '+', | ||||||
|           modifier: cost => cost + uploadFees.totalFees, |           modifier: cost => cost + uploadFees.totalFees, | ||||||
|           omit: !uploadFees.totalFees |           omit: !uploadFees.totalFees | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react' | |||||||
| import { Form, Input, SubmitButton } from './form' | import { Form, Input, SubmitButton } from './form' | ||||||
| import { useMe } from './me' | import { useMe } from './me' | ||||||
| import UpBolt from '@/svgs/bolt.svg' | import UpBolt from '@/svgs/bolt.svg' | ||||||
| import { amountSchema } from '@/lib/validate' | import { amountSchema, boostSchema } from '@/lib/validate' | ||||||
| import { useToast } from './toast' | import { useToast } from './toast' | ||||||
| import { useLightning } from './lightning' | import { useLightning } from './lightning' | ||||||
| import { nextTip, defaultTipIncludingRandom } from './upvote' | import { nextTip, defaultTipIncludingRandom } from './upvote' | ||||||
| @ -12,6 +12,7 @@ import { ZAP_UNDO_DELAY_MS } from '@/lib/constants' | |||||||
| import { usePaidMutation } from './use-paid-mutation' | import { usePaidMutation } from './use-paid-mutation' | ||||||
| import { ACT_MUTATION } from '@/fragments/paidAction' | import { ACT_MUTATION } from '@/fragments/paidAction' | ||||||
| import { meAnonSats } from '@/lib/apollo' | import { meAnonSats } from '@/lib/apollo' | ||||||
|  | import { BoostItemInput } from './adv-post-form' | ||||||
| 
 | 
 | ||||||
| const defaultTips = [100, 1000, 10_000, 100_000] | const defaultTips = [100, 1000, 10_000, 100_000] | ||||||
| 
 | 
 | ||||||
| @ -53,7 +54,39 @@ const setItemMeAnonSats = ({ id, amount }) => { | |||||||
|   window.localStorage.setItem(storageKey, existingAmount + amount) |   window.localStorage.setItem(storageKey, existingAmount + amount) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export default function ItemAct ({ onClose, item, down, children, abortSignal }) { | function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'BOOST' }) { | ||||||
|  |   return ( | ||||||
|  |     <Form | ||||||
|  |       initial={{ | ||||||
|  |         amount: step | ||||||
|  |       }} | ||||||
|  |       schema={boostSchema} | ||||||
|  |       onSubmit={onSubmit} | ||||||
|  |     > | ||||||
|  |       <BoostItemInput | ||||||
|  |         label='add boost' | ||||||
|  |         act | ||||||
|  |         name='amount' | ||||||
|  |         type='number' | ||||||
|  |         innerRef={inputRef} | ||||||
|  |         overrideValue={oValue} | ||||||
|  |         sub={item.sub} | ||||||
|  |         step={step} | ||||||
|  |         required | ||||||
|  |         autoFocus | ||||||
|  |         item={item} | ||||||
|  |       /> | ||||||
|  |       {children} | ||||||
|  |       <div className='d-flex mt-3'> | ||||||
|  |         <SubmitButton variant='success' className='ms-auto mt-1 px-4' value={act}> | ||||||
|  |           boost | ||||||
|  |         </SubmitButton> | ||||||
|  |       </div> | ||||||
|  |     </Form> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) { | ||||||
|   const inputRef = useRef(null) |   const inputRef = useRef(null) | ||||||
|   const { me } = useMe() |   const { me } = useMe() | ||||||
|   const [oValue, setOValue] = useState() |   const [oValue, setOValue] = useState() | ||||||
| @ -62,7 +95,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal }) | |||||||
|     inputRef.current?.focus() |     inputRef.current?.focus() | ||||||
|   }, [onClose, item.id]) |   }, [onClose, item.id]) | ||||||
| 
 | 
 | ||||||
|   const act = useAct() |   const actor = useAct() | ||||||
|   const strike = useLightning() |   const strike = useLightning() | ||||||
| 
 | 
 | ||||||
|   const onSubmit = useCallback(async ({ amount }) => { |   const onSubmit = useCallback(async ({ amount }) => { | ||||||
| @ -76,18 +109,18 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal }) | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     const { error } = await act({ |     const { error } = await actor({ | ||||||
|       variables: { |       variables: { | ||||||
|         id: item.id, |         id: item.id, | ||||||
|         sats: Number(amount), |         sats: Number(amount), | ||||||
|         act: down ? 'DONT_LIKE_THIS' : 'TIP' |         act | ||||||
|       }, |       }, | ||||||
|       optimisticResponse: me |       optimisticResponse: me | ||||||
|         ? { |         ? { | ||||||
|             act: { |             act: { | ||||||
|               __typename: 'ItemActPaidAction', |               __typename: 'ItemActPaidAction', | ||||||
|               result: { |               result: { | ||||||
|                 id: item.id, sats: Number(amount), act: down ? 'DONT_LIKE_THIS' : 'TIP', path: item.path |                 id: item.id, sats: Number(amount), act, path: item.path | ||||||
|               } |               } | ||||||
|             } |             } | ||||||
|           } |           } | ||||||
| @ -101,36 +134,40 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal }) | |||||||
|     }) |     }) | ||||||
|     if (error) throw error |     if (error) throw error | ||||||
|     addCustomTip(Number(amount)) |     addCustomTip(Number(amount)) | ||||||
|   }, [me, act, down, item.id, onClose, abortSignal, strike]) |   }, [me, actor, act, item.id, onClose, abortSignal, strike]) | ||||||
| 
 | 
 | ||||||
|   return ( |   return act === 'BOOST' | ||||||
|     <Form |     ? <BoostForm step={step} onSubmit={onSubmit} item={item} oValue={oValue} inputRef={inputRef} act={act}>{children}</BoostForm> | ||||||
|       initial={{ |     : ( | ||||||
|         amount: defaultTipIncludingRandom(me?.privates) || defaultTips[0], |       <Form | ||||||
|         default: false |         initial={{ | ||||||
|       }} |           amount: defaultTipIncludingRandom(me?.privates) || defaultTips[0] | ||||||
|       schema={amountSchema} |         }} | ||||||
|       onSubmit={onSubmit} |         schema={amountSchema} | ||||||
|     > |         onSubmit={onSubmit} | ||||||
|       <Input |       > | ||||||
|         label='amount' |         <Input | ||||||
|         name='amount' |           label='amount' | ||||||
|         type='number' |           name='amount' | ||||||
|         innerRef={inputRef} |           type='number' | ||||||
|         overrideValue={oValue} |           innerRef={inputRef} | ||||||
|         required |           overrideValue={oValue} | ||||||
|         autoFocus |           step={step} | ||||||
|         append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} |           required | ||||||
|       /> |           autoFocus | ||||||
|       <div> |           append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} | ||||||
|         <Tips setOValue={setOValue} /> |         /> | ||||||
|       </div> | 
 | ||||||
|       {children} |         <div> | ||||||
|       <div className='d-flex mt-3'> |           <Tips setOValue={setOValue} /> | ||||||
|         <SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton> |         </div> | ||||||
|       </div> |         {children} | ||||||
|     </Form> |         <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 }) { | function modifyActCache (cache, { result, invoice }) { | ||||||
| @ -156,6 +193,12 @@ function modifyActCache (cache, { result, invoice }) { | |||||||
|           return existingSats + sats |           return existingSats + sats | ||||||
|         } |         } | ||||||
|         return existingSats |         return existingSats | ||||||
|  |       }, | ||||||
|  |       boost: (existingBoost = 0) => { | ||||||
|  |         if (act === 'BOOST') { | ||||||
|  |           return existingBoost + sats | ||||||
|  |         } | ||||||
|  |         return existingBoost | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   }) |   }) | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| import { string } from 'yup' | import { string } from 'yup' | ||||||
| import Toc from './table-of-contents' | import Toc from './table-of-contents' | ||||||
| import Badge from 'react-bootstrap/Badge' |  | ||||||
| import Button from 'react-bootstrap/Button' | import Button from 'react-bootstrap/Button' | ||||||
| import Image from 'react-bootstrap/Image' | import Image from 'react-bootstrap/Image' | ||||||
| import { SearchTitle } from './item' | import { SearchTitle } from './item' | ||||||
| @ -11,6 +10,8 @@ import EmailIcon from '@/svgs/mail-open-line.svg' | |||||||
| import Share from './share' | import Share from './share' | ||||||
| import Hat from './hat' | import Hat from './hat' | ||||||
| import { MEDIA_URL } from '@/lib/constants' | import { MEDIA_URL } from '@/lib/constants' | ||||||
|  | import { abbrNum } from '@/lib/format' | ||||||
|  | import { Badge } from 'react-bootstrap' | ||||||
| 
 | 
 | ||||||
| export default function ItemJob ({ item, toc, rank, children }) { | export default function ItemJob ({ item, toc, rank, children }) { | ||||||
|   const isEmail = string().email().isValidSync(item.url) |   const isEmail = string().email().isValidSync(item.url) | ||||||
| @ -50,6 +51,7 @@ export default function ItemJob ({ item, toc, rank, children }) { | |||||||
|               </>} |               </>} | ||||||
|             <wbr /> |             <wbr /> | ||||||
|             <span> \ </span> |             <span> \ </span> | ||||||
|  |             {item.boost > 0 && <span>{abbrNum(item.boost)} boost \ </span>} | ||||||
|             <span> |             <span> | ||||||
|               <Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'> |               <Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'> | ||||||
|                 @{item.user.name}<Hat className='ms-1 fill-grey' user={item.user} height={12} width={12} /> |                 @{item.user.name}<Hat className='ms-1 fill-grey' user={item.user} height={12} width={12} /> | ||||||
| @ -59,17 +61,21 @@ export default function ItemJob ({ item, toc, rank, children }) { | |||||||
|                 {timeSince(new Date(item.createdAt))} |                 {timeSince(new Date(item.createdAt))} | ||||||
|               </Link> |               </Link> | ||||||
|             </span> |             </span> | ||||||
|             {item.mine && |             {item.subName && | ||||||
|  |               <Link href={`/~${item.subName}`}> | ||||||
|  |                 {' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge> | ||||||
|  |               </Link>} | ||||||
|  |             {item.status === 'STOPPED' && | ||||||
|  |               <>{' '}<Badge bg='info' className={styles.badge}>stopped</Badge></>} | ||||||
|  |             {item.mine && !item.deletedAt && | ||||||
|               ( |               ( | ||||||
|                 <> |                 <> | ||||||
|                   <wbr /> |                   <wbr /> | ||||||
|                   <span> \ </span> |                   <span> \ </span> | ||||||
|                   <Link href={`/items/${item.id}/edit`} className='text-reset'> |                   <Link href={`/items/${item.id}/edit`} className='text-reset fw-bold'> | ||||||
|                     edit |                     edit | ||||||
|                   </Link> |                   </Link> | ||||||
|                   {item.status !== 'ACTIVE' && <span className='ms-1 fw-bold text-boost'> {item.status}</span>} |  | ||||||
|                 </>)} |                 </>)} | ||||||
|             {item.maxBid > 0 && item.status === 'ACTIVE' && <Badge className={`${styles.newComment} ms-1`}>PROMOTED</Badge>} |  | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         {toc && |         {toc && | ||||||
|  | |||||||
| @ -24,6 +24,7 @@ import removeMd from 'remove-markdown' | |||||||
| import { decodeProxyUrl, IMGPROXY_URL_REGEXP, parseInternalLinks } from '@/lib/url' | import { decodeProxyUrl, IMGPROXY_URL_REGEXP, parseInternalLinks } from '@/lib/url' | ||||||
| import ItemPopover from './item-popover' | import ItemPopover from './item-popover' | ||||||
| import { useMe } from './me' | import { useMe } from './me' | ||||||
|  | import Boost from './boost-button' | ||||||
| 
 | 
 | ||||||
| function onItemClick (e, router, item) { | function onItemClick (e, router, item) { | ||||||
|   const viewedAt = commentsViewedAt(item) |   const viewedAt = commentsViewedAt(item) | ||||||
| @ -105,11 +106,13 @@ export default function Item ({ | |||||||
|       <div className={classNames(styles.item, itemClassName)}> |       <div className={classNames(styles.item, itemClassName)}> | ||||||
|         {item.position && (pinnable || !item.subName) |         {item.position && (pinnable || !item.subName) | ||||||
|           ? <Pin width={24} height={24} className={styles.pin} /> |           ? <Pin width={24} height={24} className={styles.pin} /> | ||||||
|           : item.meDontLikeSats > item.meSats |           : item.mine | ||||||
|             ? <DownZap width={24} height={24} className={styles.dontLike} item={item} /> |             ? <Boost item={item} className={styles.upvote} /> | ||||||
|             : Number(item.user?.id) === USER_ID.ad |             : item.meDontLikeSats > item.meSats | ||||||
|               ? <AdIcon width={24} height={24} className={styles.ad} /> |               ? <DownZap width={24} height={24} className={styles.dontLike} item={item} /> | ||||||
|               : <UpVote item={item} className={styles.upvote} />} |               : 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.hunk}> | ||||||
|           <div className={`${styles.main} flex-wrap`}> |           <div className={`${styles.main} flex-wrap`}> | ||||||
|             <Link |             <Link | ||||||
|  | |||||||
| @ -46,6 +46,12 @@ a.title:visited { | |||||||
|     margin-bottom: .15rem; |     margin-bottom: .15rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .badge { | ||||||
|  |     vertical-align: middle; | ||||||
|  |     margin-top: -1px; | ||||||
|  |     margin-left: 0.1rem; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .newComment { | .newComment { | ||||||
|     color: var(--theme-grey) !important; |     color: var(--theme-grey) !important; | ||||||
|     background: var(--theme-clickToContextColor) !important; |     background: var(--theme-clickToContextColor) !important; | ||||||
|  | |||||||
| @ -15,7 +15,7 @@ export default function Items ({ ssrData, variables = {}, query, destructureData | |||||||
|   const Foooter = Footer || MoreFooter |   const Foooter = Footer || MoreFooter | ||||||
|   const dat = useData(data, ssrData) |   const dat = useData(data, ssrData) | ||||||
| 
 | 
 | ||||||
|   const { items, pins, cursor } = useMemo(() => { |   const { items, pins, ad, cursor } = useMemo(() => { | ||||||
|     if (!dat) return {} |     if (!dat) return {} | ||||||
|     if (destructureData) { |     if (destructureData) { | ||||||
|       return destructureData(dat) |       return destructureData(dat) | ||||||
| @ -50,6 +50,7 @@ export default function Items ({ ssrData, variables = {}, query, destructureData | |||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <div className={styles.grid}> |       <div className={styles.grid}> | ||||||
|  |         {ad && <ListItem item={ad} ad />} | ||||||
|         {itemsWithPins.filter(filter).map((item, i) => ( |         {itemsWithPins.filter(filter).map((item, i) => ( | ||||||
|           <ListItem key={`${item.id}-${i + 1}`} item={item} rank={rank && i + 1} itemClassName={variables.includeComments ? 'py-2' : ''} pinnable={isHome ? false : pins?.length > 0} /> |           <ListItem key={`${item.id}-${i + 1}`} item={item} rank={rank && i + 1} itemClassName={variables.includeComments ? 'py-2' : ''} pinnable={isHome ? false : pins?.length > 0} /> | ||||||
|         ))} |         ))} | ||||||
|  | |||||||
| @ -1,46 +1,41 @@ | |||||||
| import { Checkbox, Form, Input, MarkdownInput } from './form' | import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form' | ||||||
| import Row from 'react-bootstrap/Row' | import Row from 'react-bootstrap/Row' | ||||||
| import Col from 'react-bootstrap/Col' | import Col from 'react-bootstrap/Col' | ||||||
| import InputGroup from 'react-bootstrap/InputGroup' |  | ||||||
| import Image from 'react-bootstrap/Image' | import Image from 'react-bootstrap/Image' | ||||||
| import BootstrapForm from 'react-bootstrap/Form' |  | ||||||
| import Alert from 'react-bootstrap/Alert' |  | ||||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||||
| import Info from './info' | import Info from './info' | ||||||
| import AccordianItem from './accordian-item' |  | ||||||
| import styles from '@/styles/post.module.css' | import styles from '@/styles/post.module.css' | ||||||
| import { useLazyQuery, gql } from '@apollo/client' | import { useLazyQuery, gql } from '@apollo/client' | ||||||
| import Link from 'next/link' |  | ||||||
| import { usePrice } from './price' |  | ||||||
| import Avatar from './avatar' | import Avatar from './avatar' | ||||||
| import { jobSchema } from '@/lib/validate' | import { jobSchema } from '@/lib/validate' | ||||||
| import { MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants' | import { BOOST_MIN, BOOST_MULT, MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants' | ||||||
| import { ItemButtonBar } from './post' |  | ||||||
| import { useFormikContext } from 'formik' |  | ||||||
| import { UPSERT_JOB } from '@/fragments/paidAction' | import { UPSERT_JOB } from '@/fragments/paidAction' | ||||||
| import useItemSubmit from './use-item-submit' | import useItemSubmit from './use-item-submit' | ||||||
| 
 | import { BoostInput } from './adv-post-form' | ||||||
| function satsMin2Mo (minute) { | import { numWithUnits, giveOrdinalSuffix } from '@/lib/format' | ||||||
|   return minute * 30 * 24 * 60 | import useDebounceCallback from './use-debounce-callback' | ||||||
| } | import FeeButton from './fee-button' | ||||||
| 
 | import CancelButton from './cancel-button' | ||||||
| function PriceHint ({ monthly }) { |  | ||||||
|   const { price, fiatSymbol } = usePrice() |  | ||||||
| 
 |  | ||||||
|   if (!price || !monthly) { |  | ||||||
|     return null |  | ||||||
|   } |  | ||||||
|   const fixed = (n, f) => Number.parseFloat(n).toFixed(f) |  | ||||||
|   const fiat = fixed((price / 100000000) * monthly, 0) |  | ||||||
| 
 |  | ||||||
|   return <span className='text-muted'>{monthly} sats/mo which is {fiatSymbol}{fiat}/mo</span> |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| // need to recent list items
 | // need to recent list items
 | ||||||
| export default function JobForm ({ item, sub }) { | export default function JobForm ({ item, sub }) { | ||||||
|   const storageKeyPrefix = item ? undefined : `${sub.name}-job` |   const storageKeyPrefix = item ? undefined : `${sub.name}-job` | ||||||
|   const [logoId, setLogoId] = useState(item?.uploadId) |   const [logoId, setLogoId] = useState(item?.uploadId) | ||||||
| 
 | 
 | ||||||
|  |   const [getAuctionPosition, { data }] = useLazyQuery(gql` | ||||||
|  |      query AuctionPosition($id: ID, $boost: Int) { | ||||||
|  |        auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, boost: $boost) | ||||||
|  |      }`,
 | ||||||
|  |   { fetchPolicy: 'cache-and-network' }) | ||||||
|  | 
 | ||||||
|  |   const getPositionDebounce = useDebounceCallback((...args) => getAuctionPosition(...args), 1000, [getAuctionPosition]) | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (item?.boost) { | ||||||
|  |       getPositionDebounce({ variables: { boost: item.boost, id: item.id } }) | ||||||
|  |     } | ||||||
|  |   }, [item?.boost]) | ||||||
|  | 
 | ||||||
|   const extraValues = logoId ? { logo: Number(logoId) } : {} |   const extraValues = logoId ? { logo: Number(logoId) } : {} | ||||||
|   const onSubmit = useItemSubmit(UPSERT_JOB, { item, sub, extraValues }) |   const onSubmit = useItemSubmit(UPSERT_JOB, { item, sub, extraValues }) | ||||||
| 
 | 
 | ||||||
| @ -53,13 +48,13 @@ export default function JobForm ({ item, sub }) { | |||||||
|           company: item?.company || '', |           company: item?.company || '', | ||||||
|           location: item?.location || '', |           location: item?.location || '', | ||||||
|           remote: item?.remote || false, |           remote: item?.remote || false, | ||||||
|  |           boost: item?.boost || '', | ||||||
|           text: item?.text || '', |           text: item?.text || '', | ||||||
|           url: item?.url || '', |           url: item?.url || '', | ||||||
|           maxBid: item?.maxBid || 0, |  | ||||||
|           stop: false, |           stop: false, | ||||||
|           start: false |           start: false | ||||||
|         }} |         }} | ||||||
|         schema={jobSchema} |         schema={jobSchema({ existingBoost: item?.boost })} | ||||||
|         storageKeyPrefix={storageKeyPrefix} |         storageKeyPrefix={storageKeyPrefix} | ||||||
|         requireSession |         requireSession | ||||||
|         onSubmit={onSubmit} |         onSubmit={onSubmit} | ||||||
| @ -115,130 +110,46 @@ export default function JobForm ({ item, sub }) { | |||||||
|           required |           required | ||||||
|           clear |           clear | ||||||
|         /> |         /> | ||||||
|         <PromoteJob item={item} sub={sub} /> |         <BoostInput | ||||||
|         {item && <StatusControl item={item} />} |           label={ | ||||||
|         <ItemButtonBar itemId={item?.id} canDelete={false} /> |             <div className='d-flex align-items-center'>boost | ||||||
|  |               <Info> | ||||||
|  |                 <ol className='line-height-md'> | ||||||
|  |                   <li>Boost ranks jobs higher based on the amount</li> | ||||||
|  |                   <li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li> | ||||||
|  |                   <li>Boost must be divisible by {numWithUnits(BOOST_MULT, { abbreviate: false })}</li> | ||||||
|  |                   <li>100% of boost goes to the territory founder and top stackers as rewards</li> | ||||||
|  |                 </ol> | ||||||
|  |               </Info> | ||||||
|  |             </div> | ||||||
|  |           } | ||||||
|  |           hint={<span className='text-muted'>{data?.auctionPosition ? `your job will rank ${giveOrdinalSuffix(data.auctionPosition)}` : 'higher boost ranks your job higher'}</span>} | ||||||
|  |           onChange={(_, e) => getPositionDebounce({ variables: { boost: Number(e.target.value), id: item?.id } })} | ||||||
|  |         /> | ||||||
|  |         <JobButtonBar itemId={item?.id} /> | ||||||
|       </Form> |       </Form> | ||||||
|     </> |     </> | ||||||
|   ) |   ) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const FormStatus = { | export function JobButtonBar ({ | ||||||
|   DIRTY: 'dirty', |   itemId, disable, className, children, handleStop, onCancel, hasCancel = true, | ||||||
|   ERROR: 'error' |   createText = 'post', editText = 'save', stopText = 'remove' | ||||||
| } | }) { | ||||||
| 
 |  | ||||||
| function PromoteJob ({ item, sub }) { |  | ||||||
|   const formik = useFormikContext() |  | ||||||
|   const [show, setShow] = useState(false) |  | ||||||
|   const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0)) |  | ||||||
|   const [getAuctionPosition, { data }] = useLazyQuery(gql` |  | ||||||
|     query AuctionPosition($id: ID, $bid: Int!) { |  | ||||||
|       auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, bid: $bid) |  | ||||||
|     }`,
 |  | ||||||
|   { fetchPolicy: 'cache-and-network' }) |  | ||||||
|   const position = data?.auctionPosition |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     const initialMaxBid = Number(item?.maxBid) || 0 |  | ||||||
|     getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } }) |  | ||||||
|     setMonthly(satsMin2Mo(initialMaxBid)) |  | ||||||
|   }, []) |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (formik?.values?.maxBid !== 0) { |  | ||||||
|       setShow(FormStatus.DIRTY) |  | ||||||
|     } |  | ||||||
|   }, [formik?.values]) |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     const hasMaxBidError = !!formik?.errors?.maxBid |  | ||||||
|     // if it's open we don't want to collapse on submit
 |  | ||||||
|     setShow(show => show || (hasMaxBidError && formik?.isSubmitting && FormStatus.ERROR)) |  | ||||||
|   }, [formik?.isSubmitting]) |  | ||||||
| 
 |  | ||||||
|   return ( |   return ( | ||||||
|     <AccordianItem |     <div className={`mt-3 ${className}`}> | ||||||
|       show={show} |       <div className='d-flex justify-content-between'> | ||||||
|       header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>} |         {itemId && | ||||||
|       body={ |           <SubmitButton valueName='status' value='STOPPED' variant='grey-medium'>{stopText}</SubmitButton>} | ||||||
|         <> |         {children} | ||||||
|           <Input |         <div className='d-flex align-items-center ms-auto'> | ||||||
|             label={ |           {hasCancel && <CancelButton onClick={onCancel} />} | ||||||
|               <div className='d-flex align-items-center'>bid |           <FeeButton | ||||||
|                 <Info> |             text={itemId ? editText : createText} | ||||||
|                   <ol> |             variant='secondary' | ||||||
|                     <li>The higher your bid the higher your job will rank</li> |             disabled={disable} | ||||||
|                     <li>You can increase, decrease, or remove your bid at anytime</li> |  | ||||||
|                     <li>You can edit or stop your job at anytime</li> |  | ||||||
|                     <li>If you run out of sats, your job will stop being promoted until you fill your wallet again</li> |  | ||||||
|                   </ol> |  | ||||||
|                 </Info> |  | ||||||
|                 <small className='text-muted ms-2'>optional</small> |  | ||||||
|               </div> |  | ||||||
|           } |  | ||||||
|             name='maxBid' |  | ||||||
|             onChange={async (formik, e) => { |  | ||||||
|               if (e.target.value >= 0 && e.target.value <= 100000000) { |  | ||||||
|                 setMonthly(satsMin2Mo(e.target.value)) |  | ||||||
|                 getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } }) |  | ||||||
|               } else { |  | ||||||
|                 setMonthly(satsMin2Mo(0)) |  | ||||||
|               } |  | ||||||
|             }} |  | ||||||
|             append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>} |  | ||||||
|             hint={<PriceHint monthly={monthly} />} |  | ||||||
|           /> |           /> | ||||||
|           <><div className='fw-bold text-muted'>This bid puts your job in position: {position}</div></> |         </div> | ||||||
|         </> |  | ||||||
|   } |  | ||||||
|     /> |  | ||||||
|   ) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| function StatusControl ({ item }) { |  | ||||||
|   let StatusComp |  | ||||||
| 
 |  | ||||||
|   if (item.status !== 'STOPPED') { |  | ||||||
|     StatusComp = () => { |  | ||||||
|       return ( |  | ||||||
|         <> |  | ||||||
| 
 |  | ||||||
|           <AccordianItem |  | ||||||
|             header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>I want to stop my job</div>} |  | ||||||
|             headerColor='var(--bs-danger)' |  | ||||||
|             body={ |  | ||||||
|               <Checkbox |  | ||||||
|                 label={<div className='fw-bold text-danger'>stop my job</div>} name='stop' inline |  | ||||||
|               /> |  | ||||||
|           } |  | ||||||
|           /> |  | ||||||
|         </> |  | ||||||
|       ) |  | ||||||
|     } |  | ||||||
|   } else if (item.status === 'STOPPED') { |  | ||||||
|     StatusComp = () => { |  | ||||||
|       return ( |  | ||||||
|         <AccordianItem |  | ||||||
|           header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>I want to resume my job</div>} |  | ||||||
|           headerColor='var(--bs-success)' |  | ||||||
|           body={ |  | ||||||
|             <Checkbox |  | ||||||
|               label={<div className='fw-bold text-success'>resume my job</div>} name='start' inline |  | ||||||
|             /> |  | ||||||
|           } |  | ||||||
|         /> |  | ||||||
|       ) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <div className='my-3 border border-3 rounded'> |  | ||||||
|       <div className='p-3'> |  | ||||||
|         <BootstrapForm.Label>job control</BootstrapForm.Label> |  | ||||||
|         {item.status === 'NOSATS' && |  | ||||||
|           <Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' className='text-reset text-underline'>fund your wallet</Link> or reduce bid to continue promoting your job</Alert>} |  | ||||||
|         <StatusComp /> |  | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   ) |   ) | ||||||
|  | |||||||
| @ -163,7 +163,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) { | |||||||
|           } |           } | ||||||
|         }} |         }} | ||||||
|       /> |       /> | ||||||
|       <AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item}> |       <AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub}> | ||||||
|         <MarkdownInput |         <MarkdownInput | ||||||
|           label='context' |           label='context' | ||||||
|           name='text' |           name='text' | ||||||
|  | |||||||
| @ -401,22 +401,24 @@ export function Sorts ({ sub, prefix, className }) { | |||||||
|           <Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link> |           <Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link> | ||||||
|         </Link> |         </Link> | ||||||
|       </Nav.Item> |       </Nav.Item> | ||||||
|       <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' && |       {sub !== 'jobs' && | ||||||
|         <Nav.Item className={className}> |         <> | ||||||
|           <Link |           <Nav.Item className={className}> | ||||||
|             href={{ |             <Link href={prefix + '/random'} passHref legacyBehavior> | ||||||
|               pathname: '/~/top/[type]/[when]', |               <Nav.Link eventKey='random' className={styles.navLink}>random</Nav.Link> | ||||||
|               query: { type: 'posts', when: 'day', sub } |             </Link> | ||||||
|             }} as={prefix + '/top/posts/day'} passHref legacyBehavior |           </Nav.Item> | ||||||
|           > |           <Nav.Item className={className}> | ||||||
|             <Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link> |             <Link | ||||||
|           </Link> |               href={{ | ||||||
|         </Nav.Item>} |                 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 |     invoiceId = invoice.item.poll?.meInvoiceId | ||||||
|     invoiceActionState = invoice.item.poll?.meInvoiceActionState |     invoiceActionState = invoice.item.poll?.meInvoiceActionState | ||||||
|   } else { |   } else { | ||||||
|     actionString = `${invoice.actionType === 'ZAP' |     if (invoice.actionType === 'ZAP') { | ||||||
|       ? invoice.item.root?.bounty ? 'bounty payment' : 'zap' |       if (invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root.mine) { | ||||||
|       : 'downzap'} on ${itemType} ` |         actionString = 'bounty payment' | ||||||
|  |       } else { | ||||||
|  |         actionString = 'zap' | ||||||
|  |       } | ||||||
|  |     } else if (invoice.actionType === 'DOWN_ZAP') { | ||||||
|  |       actionString = 'downzap' | ||||||
|  |     } else if (invoice.actionType === 'BOOST') { | ||||||
|  |       actionString = 'boost' | ||||||
|  |     } | ||||||
|  |     actionString = `${actionString} on ${itemType} ` | ||||||
|     retry = actRetry; |     retry = actRetry; | ||||||
|     ({ id: invoiceId, actionState: invoiceActionState } = invoice.itemAct.invoice) |     ({ id: invoiceId, actionState: invoiceActionState } = invoice.itemAct.invoice) | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -62,7 +62,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { | |||||||
|           : null} |           : null} | ||||||
|         maxLength={MAX_POLL_CHOICE_LENGTH} |         maxLength={MAX_POLL_CHOICE_LENGTH} | ||||||
|       /> |       /> | ||||||
|       <AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item}> |       <AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub}> | ||||||
|         <DateTimeInput |         <DateTimeInput | ||||||
|           isClearable |           isClearable | ||||||
|           label='poll expiration' |           label='poll expiration' | ||||||
|  | |||||||
| @ -187,7 +187,7 @@ export default function Post ({ sub }) { | |||||||
| export function ItemButtonBar ({ | export function ItemButtonBar ({ | ||||||
|   itemId, canDelete = true, disable, |   itemId, canDelete = true, disable, | ||||||
|   className, children, onDelete, onCancel, hasCancel = true, |   className, children, onDelete, onCancel, hasCancel = true, | ||||||
|   createText = 'post', editText = 'save' |   createText = 'post', editText = 'save', deleteText = 'delete' | ||||||
| }) { | }) { | ||||||
|   const router = useRouter() |   const router = useRouter() | ||||||
| 
 | 
 | ||||||
| @ -199,7 +199,7 @@ export function ItemButtonBar ({ | |||||||
|             itemId={itemId} |             itemId={itemId} | ||||||
|             onDelete={onDelete || (() => router.push(`/items/${itemId}`))} |             onDelete={onDelete || (() => router.push(`/items/${itemId}`))} | ||||||
|           > |           > | ||||||
|             <Button variant='grey-medium'>delete</Button> |             <Button variant='grey-medium'>{deleteText}</Button> | ||||||
|           </Delete>} |           </Delete>} | ||||||
|         {children} |         {children} | ||||||
|         <div className='d-flex align-items-center ms-auto'> |         <div className='d-flex align-items-center ms-auto'> | ||||||
|  | |||||||
| @ -77,6 +77,7 @@ export default function TerritoryForm ({ sub }) { | |||||||
|       lines.paid = { |       lines.paid = { | ||||||
|         term: `- ${abbrNum(alreadyBilled)} sats`, |         term: `- ${abbrNum(alreadyBilled)} sats`, | ||||||
|         label: 'already paid', |         label: 'already paid', | ||||||
|  |         op: '-', | ||||||
|         modifier: cost => cost - alreadyBilled |         modifier: cost => cost - alreadyBilled | ||||||
|       } |       } | ||||||
|       return lines |       return lines | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ import Popover from 'react-bootstrap/Popover' | |||||||
| import { useShowModal } from './modal' | import { useShowModal } from './modal' | ||||||
| import { numWithUnits } from '@/lib/format' | import { numWithUnits } from '@/lib/format' | ||||||
| import { Dropdown } from 'react-bootstrap' | import { Dropdown } from 'react-bootstrap' | ||||||
|  | import classNames from 'classnames' | ||||||
| 
 | 
 | ||||||
| const UpvotePopover = ({ target, show, handleClose }) => { | const UpvotePopover = ({ target, show, handleClose }) => { | ||||||
|   const { me } = useMe() |   const { me } = useMe() | ||||||
| @ -226,7 +227,14 @@ export default function UpVote ({ item, className }) { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const fillColor = hover || pending ? nextColor : color |   const fillColor = meSats && (hover || pending ? nextColor : color) | ||||||
|  | 
 | ||||||
|  |   const style = useMemo(() => (fillColor | ||||||
|  |     ? { | ||||||
|  |         fill: fillColor, | ||||||
|  |         filter: `drop-shadow(0 0 6px ${fillColor}90)` | ||||||
|  |       } | ||||||
|  |     : undefined), [fillColor]) | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div ref={ref} className='upvoteParent'> |     <div ref={ref} className='upvoteParent'> | ||||||
| @ -236,7 +244,7 @@ export default function UpVote ({ item, className }) { | |||||||
|       > |       > | ||||||
|         <ActionTooltip notForm disable={disabled} overlayText={overlayText}> |         <ActionTooltip notForm disable={disabled} overlayText={overlayText}> | ||||||
|           <div |           <div | ||||||
|             className={`${disabled ? styles.noSelfTips : ''} ${styles.upvoteWrapper}`} |             className={classNames(disabled && styles.noSelfTips, styles.upvoteWrapper)} | ||||||
|           > |           > | ||||||
|             <UpBolt |             <UpBolt | ||||||
|               onPointerEnter={() => setHover(true)} |               onPointerEnter={() => setHover(true)} | ||||||
| @ -244,19 +252,12 @@ export default function UpVote ({ item, className }) { | |||||||
|               onTouchEnd={() => setHover(false)} |               onTouchEnd={() => setHover(false)} | ||||||
|               width={26} |               width={26} | ||||||
|               height={26} |               height={26} | ||||||
|               className={ |               className={classNames(styles.upvote, | ||||||
|                       `${styles.upvote} |                 className, | ||||||
|                       ${className || ''} |                 disabled && styles.noSelfTips, | ||||||
|                       ${disabled ? styles.noSelfTips : ''} |                 meSats && styles.voted, | ||||||
|                       ${meSats ? styles.voted : ''} |                 pending && styles.pending)} | ||||||
|                       ${pending ? styles.pending : ''}` |               style={style} | ||||||
|                     } |  | ||||||
|               style={meSats || hover || pending |  | ||||||
|                 ? { |  | ||||||
|                     fill: fillColor, |  | ||||||
|                     filter: `drop-shadow(0 0 6px ${fillColor}90)` |  | ||||||
|                   } |  | ||||||
|                 : undefined} |  | ||||||
|             /> |             /> | ||||||
|           </div> |           </div> | ||||||
|         </ActionTooltip> |         </ActionTooltip> | ||||||
|  | |||||||
| @ -13,8 +13,7 @@ | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .noSelfTips { | .noSelfTips { | ||||||
|     fill: transparent !important; |     transform: scaleX(-1); | ||||||
|     filter: none !important; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .upvoteWrapper:not(.noSelfTips):hover { | .upvoteWrapper:not(.noSelfTips):hover { | ||||||
|  | |||||||
| @ -24,7 +24,7 @@ export default function useItemSubmit (mutation, | |||||||
|   const { me } = useMe() |   const { me } = useMe() | ||||||
| 
 | 
 | ||||||
|   return useCallback( |   return useCallback( | ||||||
|     async ({ boost, crosspost, title, options, bounty, maxBid, start, stop, ...values }, { resetForm }) => { |     async ({ boost, crosspost, title, options, bounty, status, ...values }, { resetForm }) => { | ||||||
|       if (options) { |       if (options) { | ||||||
|         // remove existing poll options since else they will be appended as duplicates
 |         // remove existing poll options since else they will be appended as duplicates
 | ||||||
|         options = options.slice(item?.poll?.options?.length || 0).filter(o => o.trim().length > 0) |         options = options.slice(item?.poll?.options?.length || 0).filter(o => o.trim().length > 0) | ||||||
| @ -44,10 +44,9 @@ export default function useItemSubmit (mutation, | |||||||
|         variables: { |         variables: { | ||||||
|           id: item?.id, |           id: item?.id, | ||||||
|           sub: item?.subName || sub?.name, |           sub: item?.subName || sub?.name, | ||||||
|           boost: boost ? Number(boost) : undefined, |           boost: boost ? Number(boost) : item?.boost ? Number(item.boost) : undefined, | ||||||
|           bounty: bounty ? Number(bounty) : undefined, |           bounty: bounty ? Number(bounty) : undefined, | ||||||
|           maxBid: (maxBid || Number(maxBid) === 0) ? Number(maxBid) : undefined, |           status: status === 'STOPPED' ? 'STOPPED' : 'ACTIVE', | ||||||
|           status: start ? 'ACTIVE' : stop ? 'STOPPED' : undefined, |  | ||||||
|           title: title?.trim(), |           title: title?.trim(), | ||||||
|           options, |           options, | ||||||
|           ...values, |           ...values, | ||||||
|  | |||||||
| @ -46,15 +46,14 @@ export const ITEM_FIELDS = gql` | |||||||
|     ncomments |     ncomments | ||||||
|     commentSats |     commentSats | ||||||
|     lastCommentAt |     lastCommentAt | ||||||
|     maxBid |  | ||||||
|     isJob |     isJob | ||||||
|  |     status | ||||||
|     company |     company | ||||||
|     location |     location | ||||||
|     remote |     remote | ||||||
|     subName |     subName | ||||||
|     pollCost |     pollCost | ||||||
|     pollExpiresAt |     pollExpiresAt | ||||||
|     status |  | ||||||
|     uploadId |     uploadId | ||||||
|     mine |     mine | ||||||
|     imgproxyUrls |     imgproxyUrls | ||||||
| @ -79,6 +78,7 @@ export const ITEM_FULL_FIELDS = gql` | |||||||
|       bounty |       bounty | ||||||
|       bountyPaidTo |       bountyPaidTo | ||||||
|       subName |       subName | ||||||
|  |       mine | ||||||
|       user { |       user { | ||||||
|         id |         id | ||||||
|         name |         name | ||||||
|  | |||||||
| @ -133,11 +133,11 @@ export const UPSERT_DISCUSSION = gql` | |||||||
| export const UPSERT_JOB = gql` | export const UPSERT_JOB = gql` | ||||||
|   ${PAID_ACTION} |   ${PAID_ACTION} | ||||||
|   mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, |   mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, | ||||||
|     $location: String, $remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, |     $location: String, $remote: Boolean, $text: String!, $url: String!, $boost: Int, | ||||||
|     $status: String, $logo: Int) { |     $status: String, $logo: Int) { | ||||||
|     upsertJob(sub: $sub, id: $id, title: $title, company: $company, |     upsertJob(sub: $sub, id: $id, title: $title, company: $company, | ||||||
|       location: $location, remote: $remote, text: $text, |       location: $location, remote: $remote, text: $text, | ||||||
|       url: $url, maxBid: $maxBid, status: $status, logo: $logo) { |       url: $url, boost: $boost, status: $status, logo: $logo) { | ||||||
|       result { |       result { | ||||||
|         id |         id | ||||||
|         deleteScheduledAt |         deleteScheduledAt | ||||||
| @ -218,8 +218,8 @@ export const CREATE_COMMENT = gql` | |||||||
| export const UPDATE_COMMENT = gql` | export const UPDATE_COMMENT = gql` | ||||||
|   ${ITEM_PAID_ACTION_FIELDS} |   ${ITEM_PAID_ACTION_FIELDS} | ||||||
|   ${PAID_ACTION} |   ${PAID_ACTION} | ||||||
|   mutation upsertComment($id: ID!, $text: String!, ${HASH_HMAC_INPUT_1}) { |   mutation upsertComment($id: ID!, $text: String!, $boost: Int, ${HASH_HMAC_INPUT_1}) { | ||||||
|     upsertComment(id: $id, text: $text, ${HASH_HMAC_INPUT_2}) { |     upsertComment(id: $id, text: $text, boost: $boost, ${HASH_HMAC_INPUT_2}) { | ||||||
|       ...ItemPaidActionFields |       ...ItemPaidActionFields | ||||||
|       ...PaidActionFields |       ...PaidActionFields | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -87,6 +87,9 @@ export const SUB_ITEMS = gql` | |||||||
|         ...CommentItemExtFields @include(if: $includeComments) |         ...CommentItemExtFields @include(if: $includeComments) | ||||||
|         position |         position | ||||||
|       } |       } | ||||||
|  |       ad { | ||||||
|  |         ...ItemFields | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| ` | ` | ||||||
|  | |||||||
| @ -180,7 +180,8 @@ function getClient (uri) { | |||||||
|                 return { |                 return { | ||||||
|                   cursor: incoming.cursor, |                   cursor: incoming.cursor, | ||||||
|                   items: [...(existing?.items || []), ...incoming.items], |                   items: [...(existing?.items || []), ...incoming.items], | ||||||
|                   pins: [...(existing?.pins || []), ...(incoming.pins || [])] |                   pins: [...(existing?.pins || []), ...(incoming.pins || [])], | ||||||
|  |                   ad: incoming?.ad || existing?.ad | ||||||
|                 } |                 } | ||||||
|               } |               } | ||||||
|             }, |             }, | ||||||
|  | |||||||
| @ -6,10 +6,10 @@ export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs') | |||||||
| export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING'] | export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING'] | ||||||
| export const NOFOLLOW_LIMIT = 1000 | export const NOFOLLOW_LIMIT = 1000 | ||||||
| export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener' | export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener' | ||||||
| export const BOOST_MULT = 5000 |  | ||||||
| export const BOOST_MIN = BOOST_MULT * 10 |  | ||||||
| export const UPLOAD_SIZE_MAX = 50 * 1024 * 1024 | export const UPLOAD_SIZE_MAX = 50 * 1024 * 1024 | ||||||
| export const UPLOAD_SIZE_MAX_AVATAR = 5 * 1024 * 1024 | export const UPLOAD_SIZE_MAX_AVATAR = 5 * 1024 * 1024 | ||||||
|  | export const BOOST_MULT = 10000 | ||||||
|  | export const BOOST_MIN = BOOST_MULT | ||||||
| export const IMAGE_PIXELS_MAX = 35000000 | export const IMAGE_PIXELS_MAX = 35000000 | ||||||
| // backwards compatibile with old media domain env var and precedence for docker url if set
 | // backwards compatibile with old media domain env var and precedence for docker url if set
 | ||||||
| export const MEDIA_URL = process.env.MEDIA_URL_DOCKER || process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}` | export const MEDIA_URL = process.env.MEDIA_URL_DOCKER || process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}` | ||||||
| @ -25,7 +25,7 @@ export const UPLOAD_TYPES_ALLOW = [ | |||||||
|   'video/webm' |   'video/webm' | ||||||
| ] | ] | ||||||
| export const AVATAR_TYPES_ALLOW = UPLOAD_TYPES_ALLOW.filter(t => t.startsWith('image/')) | export const AVATAR_TYPES_ALLOW = UPLOAD_TYPES_ALLOW.filter(t => t.startsWith('image/')) | ||||||
| export const INVOICE_ACTION_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'POLL_VOTE'] | export const INVOICE_ACTION_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'POLL_VOTE', 'BOOST'] | ||||||
| export const BOUNTY_MIN = 1000 | export const BOUNTY_MIN = 1000 | ||||||
| export const BOUNTY_MAX = 10000000 | export const BOUNTY_MAX = 10000000 | ||||||
| export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL'] | export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL'] | ||||||
| @ -91,16 +91,19 @@ export const TERRITORY_BILLING_OPTIONS = (labelPrefix) => ({ | |||||||
|   monthly: { |   monthly: { | ||||||
|     term: '+ 100k', |     term: '+ 100k', | ||||||
|     label: `${labelPrefix} month`, |     label: `${labelPrefix} month`, | ||||||
|  |     op: '+', | ||||||
|     modifier: cost => cost + TERRITORY_COST_MONTHLY |     modifier: cost => cost + TERRITORY_COST_MONTHLY | ||||||
|   }, |   }, | ||||||
|   yearly: { |   yearly: { | ||||||
|     term: '+ 1m', |     term: '+ 1m', | ||||||
|     label: `${labelPrefix} year`, |     label: `${labelPrefix} year`, | ||||||
|  |     op: '+', | ||||||
|     modifier: cost => cost + TERRITORY_COST_YEARLY |     modifier: cost => cost + TERRITORY_COST_YEARLY | ||||||
|   }, |   }, | ||||||
|   once: { |   once: { | ||||||
|     term: '+ 3m', |     term: '+ 3m', | ||||||
|     label: 'one time', |     label: 'one time', | ||||||
|  |     op: '+', | ||||||
|     modifier: cost => cost + TERRITORY_COST_ONCE |     modifier: cost => cost + TERRITORY_COST_ONCE | ||||||
|   } |   } | ||||||
| }) | }) | ||||||
|  | |||||||
| @ -110,3 +110,18 @@ export const ensureB64 = hexOrB64Url => { | |||||||
| 
 | 
 | ||||||
|   throw new Error('not a valid hex or base64 url or base64 encoded string') |   throw new Error('not a valid hex or base64 url or base64 encoded string') | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | export function giveOrdinalSuffix (i) { | ||||||
|  |   const j = i % 10 | ||||||
|  |   const k = i % 100 | ||||||
|  |   if (j === 1 && k !== 11) { | ||||||
|  |     return i + 'st' | ||||||
|  |   } | ||||||
|  |   if (j === 2 && k !== 12) { | ||||||
|  |     return i + 'nd' | ||||||
|  |   } | ||||||
|  |   if (j === 3 && k !== 13) { | ||||||
|  |     return i + 'rd' | ||||||
|  |   } | ||||||
|  |   return i + 'th' | ||||||
|  | } | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ export const defaultCommentSort = (pinned, bio, createdAt) => { | |||||||
|   return 'hot' |   return 'hot' | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const isJob = item => item.maxBid !== null && typeof item.maxBid !== 'undefined' | export const isJob = item => item.subName !== 'jobs' | ||||||
| 
 | 
 | ||||||
| // a delete directive preceded by a non word character that isn't a backtick
 | // a delete directive preceded by a non word character that isn't a backtick
 | ||||||
| const deletePattern = /\B@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi | const deletePattern = /\B@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi | ||||||
|  | |||||||
| @ -550,14 +550,13 @@ export const commentSchema = object({ | |||||||
|   text: textValidator(MAX_COMMENT_TEXT_LENGTH).required('required') |   text: textValidator(MAX_COMMENT_TEXT_LENGTH).required('required') | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
| export const jobSchema = object({ | export const jobSchema = args => object({ | ||||||
|   title: titleValidator, |   title: titleValidator, | ||||||
|   company: string().required('required').trim(), |   company: string().required('required').trim(), | ||||||
|   text: textValidator(MAX_POST_TEXT_LENGTH).required('required'), |   text: textValidator(MAX_POST_TEXT_LENGTH).required('required'), | ||||||
|   url: string() |   url: string() | ||||||
|     .or([string().email(), string().url()], 'invalid url or email') |     .or([string().email(), string().url()], 'invalid url or email') | ||||||
|     .required('required'), |     .required('required'), | ||||||
|   maxBid: intValidator.min(0, 'must be at least 0').required('required'), |  | ||||||
|   location: string().test( |   location: string().test( | ||||||
|     'no-remote', |     'no-remote', | ||||||
|     "don't write remote, just check the box", |     "don't write remote, just check the box", | ||||||
| @ -565,7 +564,8 @@ export const jobSchema = object({ | |||||||
|     .when('remote', { |     .when('remote', { | ||||||
|       is: (value) => !value, |       is: (value) => !value, | ||||||
|       then: schema => schema.required('required').trim() |       then: schema => schema.required('required').trim() | ||||||
|     }) |     }), | ||||||
|  |   ...advPostSchemaMembers(args) | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
| export const emailSchema = object({ | export const emailSchema = object({ | ||||||
| @ -585,9 +585,26 @@ export const amountSchema = object({ | |||||||
|   amount: intValidator.required('required').positive('must be positive') |   amount: intValidator.required('required').positive('must be positive') | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
|  | export const boostValidator = intValidator | ||||||
|  |   .min(BOOST_MULT, `must be at least ${BOOST_MULT}`).test({ | ||||||
|  |     name: 'boost', | ||||||
|  |     test: async boost => boost % BOOST_MULT === 0, | ||||||
|  |     message: `must be divisble be ${BOOST_MULT}` | ||||||
|  |   }) | ||||||
|  | 
 | ||||||
|  | export const boostSchema = object({ | ||||||
|  |   amount: boostValidator.required('required').positive('must be positive') | ||||||
|  | }) | ||||||
|  | 
 | ||||||
| export const actSchema = object({ | export const actSchema = object({ | ||||||
|   sats: intValidator.required('required').positive('must be positive'), |   sats: intValidator.required('required').positive('must be positive') | ||||||
|   act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS']) |     .when(['act'], ([act], schema) => { | ||||||
|  |       if (act === 'BOOST') { | ||||||
|  |         return boostValidator | ||||||
|  |       } | ||||||
|  |       return schema | ||||||
|  |     }), | ||||||
|  |   act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS', 'BOOST']) | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
| export const settingsSchema = object().shape({ | export const settingsSchema = object().shape({ | ||||||
| @ -618,7 +635,7 @@ export const settingsSchema = object().shape({ | |||||||
|       string().nullable().matches(NOSTR_PUBKEY_HEX, 'must be 64 hex chars'), |       string().nullable().matches(NOSTR_PUBKEY_HEX, 'must be 64 hex chars'), | ||||||
|       string().nullable().matches(NOSTR_PUBKEY_BECH32, 'invalid bech32 encoding')], 'invalid pubkey'), |       string().nullable().matches(NOSTR_PUBKEY_BECH32, 'invalid bech32 encoding')], 'invalid pubkey'), | ||||||
|   nostrRelays: array().of( |   nostrRelays: array().of( | ||||||
|     string().ws() |     string().ws().transform(relay => relay.startsWith('wss://') ? relay : `wss://${relay}`) | ||||||
|   ).max(NOSTR_MAX_RELAY_NUM, |   ).max(NOSTR_MAX_RELAY_NUM, | ||||||
|     ({ max, value }) => `${Math.abs(max - value.length)} too many`), |     ({ max, value }) => `${Math.abs(max - value.length)} too many`), | ||||||
|   hideBookmarks: boolean(), |   hideBookmarks: boolean(), | ||||||
|  | |||||||
| @ -49,6 +49,7 @@ export default function PostEdit ({ ssrData }) { | |||||||
|         existingBoost: { |         existingBoost: { | ||||||
|           label: 'old boost', |           label: 'old boost', | ||||||
|           term: `- ${item.boost}`, |           term: `- ${item.boost}`, | ||||||
|  |           op: '-', | ||||||
|           modifier: cost => cost - item.boost |           modifier: cost => cost - item.boost | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  | |||||||
| @ -23,12 +23,15 @@ import { useMemo } from 'react' | |||||||
| import { CompactLongCountdown } from '@/components/countdown' | import { CompactLongCountdown } from '@/components/countdown' | ||||||
| import { usePaidMutation } from '@/components/use-paid-mutation' | import { usePaidMutation } from '@/components/use-paid-mutation' | ||||||
| import { DONATE } from '@/fragments/paidAction' | import { DONATE } from '@/fragments/paidAction' | ||||||
|  | import { ITEM_FULL_FIELDS } from '@/fragments/items' | ||||||
|  | import { ListItem } from '@/components/items' | ||||||
| 
 | 
 | ||||||
| const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), { | const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), { | ||||||
|   loading: () => <GrowthPieChartSkeleton /> |   loading: () => <GrowthPieChartSkeleton /> | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
| const REWARDS_FULL = gql` | const REWARDS_FULL = gql` | ||||||
|  | ${ITEM_FULL_FIELDS} | ||||||
| { | { | ||||||
|   rewards { |   rewards { | ||||||
|     total |     total | ||||||
| @ -37,6 +40,9 @@ const REWARDS_FULL = gql` | |||||||
|       name |       name | ||||||
|       value |       value | ||||||
|     } |     } | ||||||
|  |     ad { | ||||||
|  |       ...ItemFullFields | ||||||
|  |     } | ||||||
|     leaderboard { |     leaderboard { | ||||||
|       users { |       users { | ||||||
|         id |         id | ||||||
| @ -97,7 +103,7 @@ export default function Rewards ({ ssrData }) { | |||||||
|   const { data } = useQuery(REWARDS_FULL) |   const { data } = useQuery(REWARDS_FULL) | ||||||
|   const dat = useData(data, ssrData) |   const dat = useData(data, ssrData) | ||||||
| 
 | 
 | ||||||
|   let { rewards: [{ total, sources, time, leaderboard }] } = useMemo(() => { |   let { rewards: [{ total, sources, time, leaderboard, ad }] } = useMemo(() => { | ||||||
|     return dat || { rewards: [{}] } |     return dat || { rewards: [{}] } | ||||||
|   }, [dat]) |   }, [dat]) | ||||||
| 
 | 
 | ||||||
| @ -124,12 +130,13 @@ export default function Rewards ({ ssrData }) { | |||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Layout footerLinks> |     <Layout footerLinks> | ||||||
|       <h4 className='pt-3 align-self-center text-reset'> |       {ad && | ||||||
|         <small className='text-muted'>rewards are sponsored by ...</small> |         <div className='pt-3 align-self-center' style={{ maxWidth: '480px', width: '100%' }}> | ||||||
|         <Link className='text-reset ms-2' href='/items/141924' style={{ lineHeight: 1.5, textDecoration: 'underline' }}> |           <div className='fw-bold text-muted pb-2'> | ||||||
|           SN is hiring |             top boost this month | ||||||
|         </Link> |           </div> | ||||||
|       </h4> |           <ListItem item={ad} /> | ||||||
|  |         </div>} | ||||||
|       <Row className='pb-3'> |       <Row className='pb-3'> | ||||||
|         <Col lg={leaderboard?.users && 5}> |         <Col lg={leaderboard?.users && 5}> | ||||||
|           <div |           <div | ||||||
|  | |||||||
| @ -170,7 +170,9 @@ export default function Settings ({ ssrData }) { | |||||||
|               } |               } | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             const nostrRelaysFiltered = nostrRelays?.filter(word => word.trim().length > 0) |             const nostrRelaysFiltered = nostrRelays | ||||||
|  |               ?.filter(word => word.trim().length > 0) | ||||||
|  |               .map(relay => relay.startsWith('wss://') ? relay : `wss://${relay}`) | ||||||
| 
 | 
 | ||||||
|             try { |             try { | ||||||
|               await setSettings({ |               await setSettings({ | ||||||
|  | |||||||
| @ -2,16 +2,13 @@ | |||||||
|     margin: 1rem 0; |     margin: 1rem 0; | ||||||
|     justify-content: start; |     justify-content: start; | ||||||
|     font-size: 110%; |     font-size: 110%; | ||||||
|  |     gap: 0 0.5rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .nav :global .nav-link { | .nav :global .nav-link { | ||||||
|     padding-left: 0; |     padding-left: 0; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .nav :global .nav-item:not(:first-child) { |  | ||||||
|     margin-left: 1rem; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .nav :global .active { | .nav :global .active { | ||||||
|     border-bottom: 2px solid var(--bs-primary); |     border-bottom: 2px solid var(--bs-primary); | ||||||
| } | } | ||||||
|  | |||||||
| @ -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? |   company             String? | ||||||
|   weightedVotes       Float                 @default(0) |   weightedVotes       Float                 @default(0) | ||||||
|   boost               Int                   @default(0) |   boost               Int                   @default(0) | ||||||
|  |   oldBoost            Int                   @default(0) | ||||||
|   pollCost            Int? |   pollCost            Int? | ||||||
|   paidImgLink         Boolean               @default(false) |   paidImgLink         Boolean               @default(false) | ||||||
|   commentMsats        BigInt                @default(0) |   commentMsats        BigInt                @default(0) | ||||||
| @ -804,6 +805,7 @@ enum InvoiceActionType { | |||||||
|   ITEM_UPDATE |   ITEM_UPDATE | ||||||
|   ZAP |   ZAP | ||||||
|   DOWN_ZAP |   DOWN_ZAP | ||||||
|  |   BOOST | ||||||
|   DONATE |   DONATE | ||||||
|   POLL_VOTE |   POLL_VOTE | ||||||
|   TERRITORY_CREATE |   TERRITORY_CREATE | ||||||
|  | |||||||
| @ -11,7 +11,6 @@ const ITEMS = gql` | |||||||
|         ncomments |         ncomments | ||||||
|         sats |         sats | ||||||
|         company |         company | ||||||
|         maxBid |  | ||||||
|         status |         status | ||||||
|         location |         location | ||||||
|         remote |         remote | ||||||
| @ -232,7 +231,7 @@ ${topCowboys.map((user, i) => | |||||||
| ------ | ------ | ||||||
| 
 | 
 | ||||||
| ##### Promoted jobs | ##### Promoted jobs | ||||||
| ${jobs.data.items.items.filter(i => i.maxBid > 0 && i.status === 'ACTIVE').slice(0, 5).map((item, i) => | ${jobs.data.items.items.filter(i => i.boost > 0).slice(0, 5).map((item, i) => | ||||||
|   `${i + 1}. [${item.title.trim()} \\ ${item.company} \\ ${item.location}${item.remote ? ' or Remote' : ''}](https://stacker.news/items/${item.id})\n`).join('')} |   `${i + 1}. [${item.title.trim()} \\ ${item.company} \\ ${item.location}${item.remote ? ' or Remote' : ''}](https://stacker.news/items/${item.id})\n`).join('')} | ||||||
| 
 | 
 | ||||||
| [**all jobs**](https://stacker.news/~jobs)
 | [**all jobs**](https://stacker.news/~jobs)
 | ||||||
|  | |||||||
| @ -245,6 +245,14 @@ $zindex-sticky: 900; | |||||||
|   width: fit-content; |   width: fit-content; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .line-height-sm { | ||||||
|  |   line-height: 1.25; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .line-height-md { | ||||||
|  |   line-height: 1.5; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| @media (display-mode: standalone) { | @media (display-mode: standalone) { | ||||||
|   .standalone { |   .standalone { | ||||||
|     display: flex |     display: flex | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ const MAX_OUTGOING_CLTV_DELTA = 500 // the maximum cltv delta we'll allow for th | |||||||
| export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice
 | export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice
 | ||||||
| const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request
 | const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request
 | ||||||
| const MAX_FEE_ESTIMATE_PERCENT = 0.025 // the maximum fee relative to outgoing we'll allow for the fee estimate
 | const MAX_FEE_ESTIMATE_PERCENT = 0.025 // the maximum fee relative to outgoing we'll allow for the fee estimate
 | ||||||
| const ZAP_SYBIL_FEE_MULT = 10 / 9 // the fee for the zap sybil service
 | const ZAP_SYBIL_FEE_MULT = 10 / 7 // the fee for the zap sybil service
 | ||||||
| 
 | 
 | ||||||
| /* | /* | ||||||
|   The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice. |   The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice. | ||||||
|  | |||||||
| @ -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' | } from './wallet.js' | ||||||
| import { repin } from './repin.js' | import { repin } from './repin.js' | ||||||
| import { trust } from './trust.js' | import { trust } from './trust.js' | ||||||
| import { auction } from './auction.js' |  | ||||||
| import { earn } from './earn.js' | import { earn } from './earn.js' | ||||||
| import apolloClient from '@apollo/client' | import apolloClient from '@apollo/client' | ||||||
| import { indexItem, indexAllItems } from './search.js' | import { indexItem, indexAllItems } from './search.js' | ||||||
| @ -35,6 +34,7 @@ import { | |||||||
| import { thisDay } from './thisDay.js' | import { thisDay } from './thisDay.js' | ||||||
| import { isServiceEnabled } from '@/lib/sndev.js' | import { isServiceEnabled } from '@/lib/sndev.js' | ||||||
| import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts.js' | import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts.js' | ||||||
|  | import { expireBoost } from './expireBoost.js' | ||||||
| 
 | 
 | ||||||
| const { ApolloClient, HttpLink, InMemoryCache } = apolloClient | const { ApolloClient, HttpLink, InMemoryCache } = apolloClient | ||||||
| 
 | 
 | ||||||
| @ -117,12 +117,12 @@ async function work () { | |||||||
|     await boss.work('imgproxy', jobWrapper(imgproxy)) |     await boss.work('imgproxy', jobWrapper(imgproxy)) | ||||||
|     await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages)) |     await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages)) | ||||||
|   } |   } | ||||||
|  |   await boss.work('expireBoost', jobWrapper(expireBoost)) | ||||||
|   await boss.work('weeklyPost-*', jobWrapper(weeklyPost)) |   await boss.work('weeklyPost-*', jobWrapper(weeklyPost)) | ||||||
|   await boss.work('payWeeklyPostBounty', jobWrapper(payWeeklyPostBounty)) |   await boss.work('payWeeklyPostBounty', jobWrapper(payWeeklyPostBounty)) | ||||||
|   await boss.work('repin-*', jobWrapper(repin)) |   await boss.work('repin-*', jobWrapper(repin)) | ||||||
|   await boss.work('trust', jobWrapper(trust)) |   await boss.work('trust', jobWrapper(trust)) | ||||||
|   await boss.work('timestampItem', jobWrapper(timestampItem)) |   await boss.work('timestampItem', jobWrapper(timestampItem)) | ||||||
|   await boss.work('auction', jobWrapper(auction)) |  | ||||||
|   await boss.work('earn', jobWrapper(earn)) |   await boss.work('earn', jobWrapper(earn)) | ||||||
|   await boss.work('streak', jobWrapper(computeStreaks)) |   await boss.work('streak', jobWrapper(computeStreaks)) | ||||||
|   await boss.work('checkStreak', jobWrapper(checkStreak)) |   await boss.work('checkStreak', jobWrapper(checkStreak)) | ||||||
|  | |||||||
| @ -22,7 +22,6 @@ const ITEM_SEARCH_FIELDS = gql` | |||||||
|       subName |       subName | ||||||
|     } |     } | ||||||
|     status |     status | ||||||
|     maxBid |  | ||||||
|     company |     company | ||||||
|     location |     location | ||||||
|     remote |     remote | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user