Merge branch 'master' into 103-add-other-currencies
This commit is contained in:
commit
2dd4b1ce98
|
@ -4,20 +4,22 @@ import serialize from './serial'
|
|||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||
import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
|
||||
import domino from 'domino'
|
||||
import { BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../../lib/constants'
|
||||
import { mdHas } from '../../lib/md'
|
||||
import {
|
||||
BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES,
|
||||
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST
|
||||
} from '../../lib/constants'
|
||||
|
||||
async function comments (models, id, sort) {
|
||||
async function comments (me, models, id, sort) {
|
||||
let orderBy
|
||||
switch (sort) {
|
||||
case 'top':
|
||||
orderBy = 'ORDER BY "Item"."weightedVotes" DESC, "Item".id DESC'
|
||||
orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".id DESC`
|
||||
break
|
||||
case 'recent':
|
||||
orderBy = 'ORDER BY "Item".created_at DESC, "Item".id DESC'
|
||||
break
|
||||
default:
|
||||
orderBy = COMMENTS_ORDER_BY_SATS
|
||||
orderBy = `ORDER BY ${await orderByNumerator(me, models)}/POWER(EXTRACT(EPOCH FROM ((NOW() AT TIME ZONE 'UTC') - "Item".created_at))/3600+2, 1.3) DESC NULLS LAST, "Item".id DESC`
|
||||
break
|
||||
}
|
||||
|
||||
|
@ -26,18 +28,18 @@ async function comments (models, id, sort) {
|
|||
${SELECT}, ARRAY[row_number() OVER (${orderBy}, "Item".path)] AS sort_path
|
||||
FROM "Item"
|
||||
WHERE "parentId" = $1
|
||||
${await filterClause(me, models)}
|
||||
UNION ALL
|
||||
${SELECT}, p.sort_path || row_number() OVER (${orderBy}, "Item".path)
|
||||
FROM base p
|
||||
JOIN "Item" ON "Item"."parentId" = p.id)
|
||||
JOIN "Item" ON "Item"."parentId" = p.id
|
||||
WHERE true
|
||||
${await filterClause(me, models)})
|
||||
SELECT * FROM base ORDER BY sort_path`, Number(id))
|
||||
return nestComments(flat, id)[0]
|
||||
}
|
||||
|
||||
const COMMENTS_ORDER_BY_SATS =
|
||||
'ORDER BY POWER("Item"."weightedVotes", 1.2)/POWER(EXTRACT(EPOCH FROM ((NOW() AT TIME ZONE \'UTC\') - "Item".created_at))/3600+2, 1.3) DESC NULLS LAST, "Item".id DESC'
|
||||
|
||||
export async function getItem (parent, { id }, { models }) {
|
||||
export async function getItem (parent, { id }, { me, models }) {
|
||||
const [item] = await models.$queryRaw(`
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
|
@ -67,6 +69,51 @@ function topClause (within) {
|
|||
return interval
|
||||
}
|
||||
|
||||
export async function orderByNumerator (me, models) {
|
||||
if (me) {
|
||||
const user = await models.user.findUnique({ where: { id: me.id } })
|
||||
if (user.wildWestMode) {
|
||||
return 'GREATEST("Item"."weightedVotes", POWER("Item"."weightedVotes", 1.2))'
|
||||
}
|
||||
}
|
||||
|
||||
return `(CASE WHEN "Item"."weightedVotes" > "Item"."weightedDownVotes"
|
||||
THEN 1
|
||||
ELSE -1 END
|
||||
* GREATEST(ABS("Item"."weightedVotes" - "Item"."weightedDownVotes"), POWER(ABS("Item"."weightedVotes" - "Item"."weightedDownVotes"), 1.2)))`
|
||||
}
|
||||
|
||||
export async function filterClause (me, models) {
|
||||
// by default don't include freebies unless they have upvotes
|
||||
let clause = ' AND (NOT "Item".freebie OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0'
|
||||
if (me) {
|
||||
const user = await models.user.findUnique({ where: { id: me.id } })
|
||||
// wild west mode has everything
|
||||
if (user.wildWestMode) {
|
||||
return ''
|
||||
}
|
||||
// greeter mode includes freebies if feebies haven't been flagged
|
||||
if (user.greeterMode) {
|
||||
clause = 'AND (NOT "Item".freebie OR ("Item"."weightedVotes" - "Item"."weightedDownVotes" >= 0 AND "Item".freebie)'
|
||||
}
|
||||
|
||||
// always include if it's mine
|
||||
clause += ` OR "Item"."userId" = ${me.id})`
|
||||
} else {
|
||||
// close default freebie clause
|
||||
clause += ')'
|
||||
}
|
||||
|
||||
// if the item is above the threshold or is mine
|
||||
clause += ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
|
||||
if (me) {
|
||||
clause += ` OR "Item"."userId" = ${me.id}`
|
||||
}
|
||||
clause += ')'
|
||||
|
||||
return clause
|
||||
}
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
itemRepetition: async (parent, { parentId }, { me, models }) => {
|
||||
|
@ -106,6 +153,7 @@ export default {
|
|||
WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2
|
||||
AND "pinId" IS NULL
|
||||
${activeOrMine()}
|
||||
${await filterClause(me, models)}
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $3
|
||||
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
|
||||
|
@ -117,6 +165,7 @@ export default {
|
|||
WHERE "parentId" IS NULL AND created_at <= $1
|
||||
${subClause(3)}
|
||||
${activeOrMine()}
|
||||
${await filterClause(me, models)}
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
|
||||
|
@ -128,7 +177,8 @@ export default {
|
|||
WHERE "parentId" IS NULL AND "Item".created_at <= $1
|
||||
AND "pinId" IS NULL
|
||||
${topClause(within)}
|
||||
${TOP_ORDER_BY_SATS}
|
||||
${await filterClause(me, models)}
|
||||
${await topOrderByWeightedSats(me, models)}
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
||||
break
|
||||
|
@ -151,7 +201,7 @@ export default {
|
|||
WHERE "parentId" IS NULL AND created_at <= $1
|
||||
AND "pinId" IS NULL
|
||||
${subClause(3)}
|
||||
AND status = 'ACTIVE'
|
||||
AND status = 'ACTIVE' AND "maxBid" > 0
|
||||
ORDER BY "maxBid" DESC, created_at ASC)
|
||||
UNION ALL
|
||||
(${SELECT}
|
||||
|
@ -159,7 +209,7 @@ export default {
|
|||
WHERE "parentId" IS NULL AND created_at <= $1
|
||||
AND "pinId" IS NULL
|
||||
${subClause(3)}
|
||||
AND status = 'NOSATS'
|
||||
AND ((status = 'ACTIVE' AND "maxBid" = 0) OR status = 'NOSATS')
|
||||
ORDER BY created_at DESC)
|
||||
) a
|
||||
OFFSET $2
|
||||
|
@ -177,9 +227,10 @@ export default {
|
|||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "parentId" IS NULL AND "Item".created_at <= $1 AND "Item".created_at > $3
|
||||
AND "pinId" IS NULL
|
||||
AND "pinId" IS NULL AND NOT bio
|
||||
${subClause(4)}
|
||||
${newTimedOrderByWeightedSats(1)}
|
||||
${await filterClause(me, models)}
|
||||
${await newTimedOrderByWeightedSats(me, models, 1)}
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date().setDate(new Date().getDate() - 5)), sub || 'NULL')
|
||||
}
|
||||
|
@ -189,9 +240,10 @@ export default {
|
|||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "parentId" IS NULL AND "Item".created_at <= $1
|
||||
AND "pinId" IS NULL
|
||||
AND "pinId" IS NULL AND NOT bio
|
||||
${subClause(3)}
|
||||
${newTimedOrderByWeightedSats(1)}
|
||||
${await filterClause(me, models)}
|
||||
${await newTimedOrderByWeightedSats(me, models, 1)}
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
|
||||
}
|
||||
|
@ -219,11 +271,66 @@ export default {
|
|||
pins
|
||||
}
|
||||
},
|
||||
allItems: async (parent, { cursor }, { models }) => {
|
||||
allItems: async (parent, { cursor }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
const items = await models.$queryRaw(`
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
${await filterClause(me, models)}
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $1
|
||||
LIMIT ${LIMIT}`, decodedCursor.offset)
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
},
|
||||
outlawedItems: async (parent, { cursor }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
const notMine = () => {
|
||||
return me ? ` AND "userId" <> ${me.id} ` : ''
|
||||
}
|
||||
|
||||
const items = await models.$queryRaw(`
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD}
|
||||
${notMine()}
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $1
|
||||
LIMIT ${LIMIT}`, decodedCursor.offset)
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
},
|
||||
borderlandItems: async (parent, { cursor }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
const notMine = () => {
|
||||
return me ? ` AND "userId" <> ${me.id} ` : ''
|
||||
}
|
||||
|
||||
const items = await models.$queryRaw(`
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" < 0
|
||||
AND "Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}
|
||||
${notMine()}
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $1
|
||||
LIMIT ${LIMIT}`, decodedCursor.offset)
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
},
|
||||
freebieItems: async (parent, { cursor }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
|
||||
const items = await models.$queryRaw(`
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "Item".freebie
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $1
|
||||
LIMIT ${LIMIT}`, decodedCursor.offset)
|
||||
|
@ -242,6 +349,7 @@ export default {
|
|||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "parentId" IS NOT NULL AND created_at <= $1
|
||||
${await filterClause(me, models)}
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
||||
|
@ -261,6 +369,7 @@ export default {
|
|||
FROM "Item"
|
||||
WHERE "userId" = $1 AND "parentId" IS NOT NULL
|
||||
AND created_at <= $2
|
||||
${await filterClause(me, models)}
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $3
|
||||
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
|
||||
|
@ -272,7 +381,8 @@ export default {
|
|||
WHERE "parentId" IS NOT NULL
|
||||
AND "Item".created_at <= $1
|
||||
${topClause(within)}
|
||||
${TOP_ORDER_BY_SATS}
|
||||
${await filterClause(me, models)}
|
||||
${await topOrderByWeightedSats(me, models)}
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
||||
break
|
||||
|
@ -322,8 +432,8 @@ export default {
|
|||
ORDER BY created_at DESC
|
||||
LIMIT 3`, similar)
|
||||
},
|
||||
comments: async (parent, { id, sort }, { models }) => {
|
||||
return comments(models, id, sort)
|
||||
comments: async (parent, { id, sort }, { me, models }) => {
|
||||
return comments(me, models, id, sort)
|
||||
},
|
||||
search: async (parent, { q: query, sub, cursor }, { me, models, search }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
|
@ -346,11 +456,19 @@ export default {
|
|||
bool: {
|
||||
should: [
|
||||
{ match: { status: 'ACTIVE' } },
|
||||
{ match: { status: 'NOSATS' } },
|
||||
{ match: { userId: me.id } }
|
||||
]
|
||||
}
|
||||
}
|
||||
: { match: { status: 'ACTIVE' } },
|
||||
: {
|
||||
bool: {
|
||||
should: [
|
||||
{ match: { status: 'ACTIVE' } },
|
||||
{ match: { status: 'NOSATS' } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
bool: {
|
||||
should: [
|
||||
|
@ -419,8 +537,9 @@ export default {
|
|||
}
|
||||
|
||||
// return highlights
|
||||
const items = sitems.body.hits.hits.map(e => {
|
||||
const item = e._source
|
||||
const items = sitems.body.hits.hits.map(async e => {
|
||||
// this is super inefficient but will suffice until we do something more generic
|
||||
const item = await getItem(parent, { id: e._source.id }, { me, models })
|
||||
|
||||
item.searchTitle = (e.highlight.title && e.highlight.title[0]) || item.title
|
||||
item.searchText = (e.highlight.text && e.highlight.text[0]) || item.text
|
||||
|
@ -433,17 +552,24 @@ export default {
|
|||
items
|
||||
}
|
||||
},
|
||||
auctionPosition: async (parent, { id, sub, bid }, { models }) => {
|
||||
auctionPosition: async (parent, { id, sub, bid }, { models, me }) => {
|
||||
// count items that have a bid gte to the current bid or
|
||||
// gte current bid and older
|
||||
const where = {
|
||||
where: {
|
||||
subName: sub,
|
||||
status: 'ACTIVE',
|
||||
maxBid: {
|
||||
gte: bid
|
||||
status: { not: 'STOPPED' }
|
||||
}
|
||||
}
|
||||
|
||||
if (bid > 0) {
|
||||
where.where.maxBid = { gte: bid }
|
||||
} else {
|
||||
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
|
||||
where.where.OR = [
|
||||
{ maxBid: { gt: 0 } },
|
||||
{ createdAt: { gt: createdAt } }
|
||||
]
|
||||
}
|
||||
|
||||
if (id) {
|
||||
|
@ -491,8 +617,6 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
const hasImgLink = !!(text && mdHas(text, ['link', 'image']))
|
||||
|
||||
if (id) {
|
||||
const optionCount = await models.pollOption.count({
|
||||
where: {
|
||||
|
@ -505,8 +629,8 @@ export default {
|
|||
}
|
||||
|
||||
const [item] = await serialize(models,
|
||||
models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
|
||||
Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id), hasImgLink))
|
||||
models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6) AS "Item"`,
|
||||
Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id)))
|
||||
|
||||
return item
|
||||
} else {
|
||||
|
@ -515,8 +639,8 @@ export default {
|
|||
}
|
||||
|
||||
const [item] = await serialize(models,
|
||||
models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
|
||||
title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id), hasImgLink))
|
||||
models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6, $7, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
|
||||
title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id)))
|
||||
|
||||
await createMentions(item, models)
|
||||
|
||||
|
@ -537,62 +661,36 @@ export default {
|
|||
throw new UserInputError('not a valid sub', { argumentName: 'sub' })
|
||||
}
|
||||
|
||||
if (fullSub.baseCost > maxBid) {
|
||||
throw new UserInputError(`bid must be at least ${fullSub.baseCost}`, { argumentName: 'maxBid' })
|
||||
if (maxBid < 0) {
|
||||
throw new UserInputError('bid must be at least 0', { argumentName: 'maxBid' })
|
||||
}
|
||||
|
||||
if (!location && !remote) {
|
||||
throw new UserInputError('must specify location or remote', { argumentName: 'location' })
|
||||
}
|
||||
|
||||
const checkSats = async () => {
|
||||
// check if the user has the funds to run for the first minute
|
||||
const minuteMsats = maxBid * 1000
|
||||
const user = await models.user.findUnique({ where: { id: me.id } })
|
||||
if (user.msats < minuteMsats) {
|
||||
throw new UserInputError('insufficient funds')
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
title,
|
||||
company,
|
||||
location: location.toLowerCase() === 'remote' ? undefined : location,
|
||||
remote,
|
||||
text,
|
||||
url,
|
||||
maxBid,
|
||||
subName: sub,
|
||||
userId: me.id,
|
||||
uploadId: logo
|
||||
}
|
||||
location = location.toLowerCase() === 'remote' ? undefined : location
|
||||
|
||||
let item
|
||||
if (id) {
|
||||
if (status) {
|
||||
data.status = status
|
||||
|
||||
// if the job is changing to active, we need to check they have funds
|
||||
if (status === 'ACTIVE') {
|
||||
await checkSats()
|
||||
}
|
||||
}
|
||||
|
||||
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
||||
if (Number(old.userId) !== Number(me?.id)) {
|
||||
throw new AuthenticationError('item does not belong to you')
|
||||
}
|
||||
|
||||
return await models.item.update({
|
||||
where: { id: Number(id) },
|
||||
data
|
||||
})
|
||||
([item] = await serialize(models,
|
||||
models.$queryRaw(
|
||||
`${SELECT} FROM update_job($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) AS "Item"`,
|
||||
Number(id), title, url, text, Number(maxBid), company, location, remote, Number(logo), status)))
|
||||
} else {
|
||||
([item] = await serialize(models,
|
||||
models.$queryRaw(
|
||||
`${SELECT} FROM create_job($1, $2, $3, $4, $5, $6, $7, $8, $9) AS "Item"`,
|
||||
title, url, text, Number(me.id), Number(maxBid), company, location, remote, Number(logo))))
|
||||
}
|
||||
|
||||
// before creating job, check the sats
|
||||
await checkSats()
|
||||
return await models.item.create({
|
||||
data
|
||||
})
|
||||
await createMentions(item, models)
|
||||
|
||||
return item
|
||||
},
|
||||
createComment: async (parent, { text, parentId }, { me, models }) => {
|
||||
return await createItem(parent, { text, parentId }, { me, models })
|
||||
|
@ -636,9 +734,31 @@ export default {
|
|||
vote,
|
||||
sats
|
||||
}
|
||||
},
|
||||
dontLikeThis: async (parent, { id }, { me, models }) => {
|
||||
// need to make sure we are logged in
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
}
|
||||
|
||||
// disallow self down votes
|
||||
const [item] = await models.$queryRaw(`
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
|
||||
if (item) {
|
||||
throw new UserInputError('cannot downvote your self')
|
||||
}
|
||||
|
||||
await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST})`)
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
Item: {
|
||||
isJob: async (item, args, { models }) => {
|
||||
return item.subName === 'jobs'
|
||||
},
|
||||
sub: async (item, args, { models }) => {
|
||||
if (!item.subName) {
|
||||
return null
|
||||
|
@ -710,11 +830,11 @@ export default {
|
|||
}
|
||||
return await models.user.findUnique({ where: { id: item.fwdUserId } })
|
||||
},
|
||||
comments: async (item, args, { models }) => {
|
||||
comments: async (item, args, { me, models }) => {
|
||||
if (item.comments) {
|
||||
return item.comments
|
||||
}
|
||||
return comments(models, item.id, 'hot')
|
||||
return comments(me, models, item.id, 'hot')
|
||||
},
|
||||
upvotes: async (item, args, { models }) => {
|
||||
const { sum: { sats } } = await models.itemAct.aggregate({
|
||||
|
@ -768,6 +888,25 @@ export default {
|
|||
|
||||
return sats || 0
|
||||
},
|
||||
meDontLike: async (item, args, { me, models }) => {
|
||||
if (!me) return false
|
||||
|
||||
const dontLike = await models.itemAct.findFirst({
|
||||
where: {
|
||||
itemId: Number(item.id),
|
||||
userId: me.id,
|
||||
act: 'DONT_LIKE_THIS'
|
||||
}
|
||||
})
|
||||
|
||||
return !!dontLike
|
||||
},
|
||||
outlawed: async (item, args, { me, models }) => {
|
||||
if (me && Number(item.userId) === Number(me.id)) {
|
||||
return false
|
||||
}
|
||||
return item.weightedVotes - item.weightedDownVotes <= -ITEM_FILTER_THRESHOLD
|
||||
},
|
||||
mine: async (item, args, { me, models }) => {
|
||||
return me?.id === item.userId
|
||||
},
|
||||
|
@ -860,12 +999,10 @@ export const updateItem = async (parent, { id, data: { title, url, text, boost,
|
|||
}
|
||||
}
|
||||
|
||||
const hasImgLink = !!(text && mdHas(text, ['link', 'image']))
|
||||
|
||||
const [item] = await serialize(models,
|
||||
models.$queryRaw(
|
||||
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
|
||||
Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id), hasImgLink))
|
||||
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6) AS "Item"`,
|
||||
Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id)))
|
||||
|
||||
await createMentions(item, models)
|
||||
|
||||
|
@ -893,13 +1030,11 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId }
|
|||
}
|
||||
}
|
||||
|
||||
const hasImgLink = !!(text && mdHas(text, ['link', 'image']))
|
||||
|
||||
const [item] = await serialize(models,
|
||||
models.$queryRaw(
|
||||
`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
|
||||
`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
|
||||
title, url, text, Number(boost || 0), Number(parentId), Number(me.id),
|
||||
Number(fwdUser?.id), hasImgLink))
|
||||
Number(fwdUser?.id)))
|
||||
|
||||
await createMentions(item, models)
|
||||
|
||||
|
@ -937,13 +1072,16 @@ export const SELECT =
|
|||
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
|
||||
"Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
|
||||
"Item".company, "Item".location, "Item".remote,
|
||||
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item"."paidImgLink",
|
||||
"Item".sats, "Item".ncomments, "Item"."commentSats", "Item"."lastCommentAt", ltree2text("Item"."path") AS "path"`
|
||||
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost",
|
||||
"Item".sats, "Item".ncomments, "Item"."commentSats", "Item"."lastCommentAt", "Item"."weightedVotes",
|
||||
"Item"."weightedDownVotes", "Item".freebie, ltree2text("Item"."path") AS "path"`
|
||||
|
||||
function newTimedOrderByWeightedSats (num) {
|
||||
async function newTimedOrderByWeightedSats (me, models, num) {
|
||||
return `
|
||||
ORDER BY (POWER("Item"."weightedVotes", 1.2)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 1.3) +
|
||||
ORDER BY (${await orderByNumerator(me, models)}/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 1.3) +
|
||||
("Item".boost/${BOOST_MIN}::float)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 2.6)) DESC NULLS LAST, "Item".id DESC`
|
||||
}
|
||||
|
||||
const TOP_ORDER_BY_SATS = 'ORDER BY "Item"."weightedVotes" DESC NULLS LAST, "Item".id DESC'
|
||||
async function topOrderByWeightedSats (me, models) {
|
||||
return `ORDER BY ${await orderByNumerator(me, models)} DESC NULLS LAST, "Item".id DESC`
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { AuthenticationError } from 'apollo-server-micro'
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||
import { getItem } from './item'
|
||||
import { getItem, filterClause } from './item'
|
||||
import { getInvoice } from './wallet'
|
||||
|
||||
export default {
|
||||
|
@ -76,7 +76,8 @@ export default {
|
|||
FROM "Item"
|
||||
JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
|
||||
WHERE p."userId" = $1
|
||||
AND "Item"."userId" <> $1 AND "Item".created_at <= $2`
|
||||
AND "Item"."userId" <> $1 AND "Item".created_at <= $2
|
||||
${await filterClause(me, models)}`
|
||||
)
|
||||
} else {
|
||||
queries.push(
|
||||
|
@ -86,6 +87,7 @@ export default {
|
|||
JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
|
||||
WHERE p."userId" = $1
|
||||
AND "Item"."userId" <> $1 AND "Item".created_at <= $2
|
||||
${await filterClause(me, models)}
|
||||
ORDER BY "sortTime" DESC
|
||||
LIMIT ${LIMIT}+$3)`
|
||||
)
|
||||
|
@ -96,7 +98,6 @@ export default {
|
|||
FROM "Item"
|
||||
WHERE "Item"."userId" = $1
|
||||
AND "maxBid" IS NOT NULL
|
||||
AND status <> 'STOPPED'
|
||||
AND "statusUpdatedAt" <= $2
|
||||
ORDER BY "sortTime" DESC
|
||||
LIMIT ${LIMIT}+$3)`
|
||||
|
@ -129,6 +130,7 @@ export default {
|
|||
AND "Mention".created_at <= $2
|
||||
AND "Item"."userId" <> $1
|
||||
AND (p."userId" IS NULL OR p."userId" <> $1)
|
||||
${await filterClause(me, models)}
|
||||
ORDER BY "sortTime" DESC
|
||||
LIMIT ${LIMIT}+$3)`
|
||||
)
|
||||
|
@ -162,18 +164,20 @@ export default {
|
|||
|
||||
if (meFull.noteEarning) {
|
||||
queries.push(
|
||||
`SELECT id::text, created_at AS "sortTime", FLOOR(msats / 1000) as "earnedSats",
|
||||
`SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats",
|
||||
'Earn' AS type
|
||||
FROM "Earn"
|
||||
WHERE "userId" = $1
|
||||
AND created_at <= $2`
|
||||
AND created_at <= $2
|
||||
GROUP BY "userId", created_at`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// we do all this crazy subquery stuff to make 'reward' islands
|
||||
const notifications = await models.$queryRaw(
|
||||
`SELECT MAX(id) AS id, MAX("sortTime") AS "sortTime", sum("earnedSats") AS "earnedSats", type
|
||||
`SELECT MAX(id) AS id, MAX("sortTime") AS "sortTime", sum("earnedSats") AS "earnedSats", type,
|
||||
MIN("sortTime") AS "minSortTime"
|
||||
FROM
|
||||
(SELECT *,
|
||||
CASE
|
||||
|
@ -214,6 +218,26 @@ export default {
|
|||
JobChanged: {
|
||||
item: async (n, args, { models }) => getItem(n, { id: n.id }, { models })
|
||||
},
|
||||
Earn: {
|
||||
sources: async (n, args, { me, models }) => {
|
||||
const [sources] = await models.$queryRaw(`
|
||||
SELECT
|
||||
FLOOR(sum(msats) FILTER(WHERE type = 'POST') / 1000) AS posts,
|
||||
FLOOR(sum(msats) FILTER(WHERE type = 'COMMENT') / 1000) AS comments,
|
||||
FLOOR(sum(msats) FILTER(WHERE type = 'TIP_POST' OR type = 'TIP_COMMENT') / 1000) AS tips
|
||||
FROM "Earn"
|
||||
WHERE "userId" = $1 AND created_at <= $2 AND created_at >= $3
|
||||
`, Number(me.id), new Date(n.sortTime), new Date(n.minSortTime))
|
||||
sources.posts ||= 0
|
||||
sources.comments ||= 0
|
||||
sources.tips ||= 0
|
||||
if (sources.posts + sources.comments + sources.tips > 0) {
|
||||
return sources
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
},
|
||||
Mention: {
|
||||
mention: async (n, args, { models }) => true,
|
||||
item: async (n, args, { models }) => getItem(n, { id: n.id }, { models })
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { AuthenticationError, UserInputError } from 'apollo-server-errors'
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||
import { mdHas } from '../../lib/md'
|
||||
import { createMentions, getItem, SELECT, updateItem } from './item'
|
||||
import { createMentions, getItem, SELECT, updateItem, filterClause } from './item'
|
||||
import serialize from './serial'
|
||||
|
||||
export function topClause (within) {
|
||||
|
@ -202,11 +201,9 @@ export default {
|
|||
if (user.bioId) {
|
||||
await updateItem(parent, { id: user.bioId, data: { text: bio, title: `@${user.name}'s bio` } }, { me, models })
|
||||
} else {
|
||||
const hasImgLink = !!(bio && mdHas(bio, ['link', 'image']))
|
||||
|
||||
const [item] = await serialize(models,
|
||||
models.$queryRaw(`${SELECT} FROM create_bio($1, $2, $3, $4) AS "Item"`,
|
||||
`@${user.name}'s bio`, bio, Number(me.id), hasImgLink))
|
||||
models.$queryRaw(`${SELECT} FROM create_bio($1, $2, $3) AS "Item"`,
|
||||
`@${user.name}'s bio`, bio, Number(me.id)))
|
||||
await createMentions(item, models)
|
||||
}
|
||||
|
||||
|
@ -245,7 +242,10 @@ export default {
|
|||
}
|
||||
|
||||
try {
|
||||
await models.user.update({ where: { id: me.id }, data: { email } })
|
||||
await models.user.update({
|
||||
where: { id: me.id },
|
||||
data: { email: email.toLowerCase() }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.code === 'P2002') {
|
||||
throw new UserInputError('email taken')
|
||||
|
@ -314,6 +314,7 @@ export default {
|
|||
JOIN "Item" p ON ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
|
||||
WHERE p."userId" = $1
|
||||
AND "Item".created_at > $2 AND "Item"."userId" <> $1
|
||||
${await filterClause(me, models)}
|
||||
LIMIT 1`, me.id, lastChecked)
|
||||
if (newReplies.length > 0) {
|
||||
return true
|
||||
|
@ -336,9 +337,6 @@ export default {
|
|||
|
||||
const job = await models.item.findFirst({
|
||||
where: {
|
||||
status: {
|
||||
not: 'STOPPED'
|
||||
},
|
||||
maxBid: {
|
||||
not: null
|
||||
},
|
||||
|
|
|
@ -103,11 +103,12 @@ export default {
|
|||
AND "ItemAct".created_at <= $2
|
||||
GROUP BY "Item".id)`)
|
||||
queries.push(
|
||||
`(SELECT ('earn' || "Earn".id) as id, "Earn".id as "factId", NULL as bolt11,
|
||||
created_at as "createdAt", msats,
|
||||
`(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11,
|
||||
created_at as "createdAt", sum(msats),
|
||||
0 as "msatsFee", NULL as status, 'earn' as type
|
||||
FROM "Earn"
|
||||
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2)`)
|
||||
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
|
||||
GROUP BY "userId", created_at)`)
|
||||
}
|
||||
|
||||
if (include.has('spent')) {
|
||||
|
|
|
@ -12,6 +12,9 @@ export default gql`
|
|||
search(q: String, sub: String, cursor: String): Items
|
||||
auctionPosition(sub: String, id: ID, bid: Int!): Int!
|
||||
itemRepetition(parentId: ID): Int!
|
||||
outlawedItems(cursor: String): Items
|
||||
borderlandItems(cursor: String): Items
|
||||
freebieItems(cursor: String): Items
|
||||
}
|
||||
|
||||
type ItemActResult {
|
||||
|
@ -27,6 +30,7 @@ export default gql`
|
|||
upsertPoll(id: ID, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item!
|
||||
createComment(text: String!, parentId: ID!): Item!
|
||||
updateComment(id: ID!, text: String!): Item!
|
||||
dontLikeThis(id: ID!): Boolean!
|
||||
act(id: ID!, sats: Int): ItemActResult!
|
||||
pollVote(id: ID!): ID!
|
||||
}
|
||||
|
@ -78,6 +82,9 @@ export default gql`
|
|||
lastCommentAt: String
|
||||
upvotes: Int!
|
||||
meSats: Int!
|
||||
meDontLike: Boolean!
|
||||
outlawed: Boolean!
|
||||
freebie: Boolean!
|
||||
paidImgLink: Boolean
|
||||
ncomments: Int!
|
||||
comments: [Item!]!
|
||||
|
@ -85,6 +92,7 @@ export default gql`
|
|||
position: Int
|
||||
prior: Int
|
||||
maxBid: Int
|
||||
isJob: Boolean!
|
||||
pollCost: Int
|
||||
poll: Poll
|
||||
company: String
|
||||
|
|
|
@ -32,9 +32,16 @@ export default gql`
|
|||
sortTime: String!
|
||||
}
|
||||
|
||||
type EarnSources {
|
||||
posts: Int!
|
||||
comments: Int!
|
||||
tips: Int!
|
||||
}
|
||||
|
||||
type Earn {
|
||||
earnedSats: Int!
|
||||
sortTime: String!
|
||||
sources: EarnSources
|
||||
}
|
||||
|
||||
type InvoicePaid {
|
||||
|
|
|
@ -31,7 +31,8 @@ export default gql`
|
|||
setName(name: String!): Boolean
|
||||
setSettings(tipDefault: Int!, fiatCurrency: String!, noteItemSats: Boolean!, noteEarning: Boolean!,
|
||||
noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
|
||||
noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!): User
|
||||
noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!,
|
||||
wildWestMode: Boolean!, greeterMode: Boolean!): User
|
||||
setPhoto(photoId: ID!): Int!
|
||||
upsertBio(bio: String!): User!
|
||||
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
|
||||
|
@ -73,6 +74,8 @@ export default gql`
|
|||
noteInvites: Boolean!
|
||||
noteJobIndicator: Boolean!
|
||||
hideInvoiceDesc: Boolean!
|
||||
wildWestMode: Boolean!
|
||||
greeterMode: Boolean!
|
||||
lastCheckedJobs: String
|
||||
authMethods: AuthMethods!
|
||||
}
|
||||
|
|
|
@ -73,7 +73,7 @@ export default function AdvPostForm ({ edit }) {
|
|||
label={<>forward sats to</>}
|
||||
name='forward'
|
||||
hint={<span className='text-muted'>100% of sats will be sent to this user</span>}
|
||||
prepend=<InputGroup.Text>@</InputGroup.Text>
|
||||
prepend={<InputGroup.Text>@</InputGroup.Text>}
|
||||
showValid
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -3,7 +3,6 @@ import * as Yup from 'yup'
|
|||
import { gql, useMutation } from '@apollo/client'
|
||||
import styles from './reply.module.css'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import { useState } from 'react'
|
||||
import { EditFeeButton } from './fee-button'
|
||||
|
||||
export const CommentSchema = Yup.object({
|
||||
|
@ -11,14 +10,11 @@ export const CommentSchema = Yup.object({
|
|||
})
|
||||
|
||||
export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) {
|
||||
const [hasImgLink, setHasImgLink] = useState()
|
||||
|
||||
const [updateComment] = useMutation(
|
||||
gql`
|
||||
mutation updateComment($id: ID! $text: String!) {
|
||||
updateComment(id: $id, text: $text) {
|
||||
text
|
||||
paidImgLink
|
||||
}
|
||||
}`, {
|
||||
update (cache, { data: { updateComment } }) {
|
||||
|
@ -27,9 +23,6 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
|
|||
fields: {
|
||||
text () {
|
||||
return updateComment.text
|
||||
},
|
||||
paidImgLink () {
|
||||
return updateComment.paidImgLink
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -59,11 +52,10 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
|
|||
as={TextareaAutosize}
|
||||
minRows={6}
|
||||
autoFocus
|
||||
setHasImgLink={setHasImgLink}
|
||||
required
|
||||
/>
|
||||
<EditFeeButton
|
||||
paidSats={comment.meSats} hadImgLink={comment.paidImgLink} hasImgLink={hasImgLink}
|
||||
paidSats={comment.meSats}
|
||||
parentId={comment.parentId} text='save' ChildButton={SubmitButton} variant='secondary'
|
||||
/>
|
||||
</Form>
|
||||
|
|
|
@ -13,6 +13,10 @@ import CommentEdit from './comment-edit'
|
|||
import Countdown from './countdown'
|
||||
import { COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants'
|
||||
import { ignoreClick } from '../lib/clicks'
|
||||
import { useMe } from './me'
|
||||
import DontLikeThis from './dont-link-this'
|
||||
import Flag from '../svgs/flag-fill.svg'
|
||||
import { Badge } from 'react-bootstrap'
|
||||
|
||||
function Parent ({ item, rootText }) {
|
||||
const ParentFrag = () => (
|
||||
|
@ -78,6 +82,7 @@ export default function Comment ({
|
|||
const [edit, setEdit] = useState()
|
||||
const [collapse, setCollapse] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const me = useMe()
|
||||
const router = useRouter()
|
||||
const mine = item.mine
|
||||
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
|
||||
|
@ -105,7 +110,7 @@ export default function Comment ({
|
|||
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse ? styles.collapsed : ''}`}
|
||||
>
|
||||
<div className={`${itemStyles.item} ${styles.item}`}>
|
||||
<UpVote item={item} className={styles.upvote} />
|
||||
{item.meDontLike ? <Flag width={24} height={24} className={`${styles.dontLike}`} /> : <UpVote item={item} className={styles.upvote} />}
|
||||
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
||||
<div className='d-flex align-items-center'>
|
||||
<div className={`${itemStyles.other} ${styles.other}`}>
|
||||
|
@ -128,6 +133,9 @@ export default function Comment ({
|
|||
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
|
||||
</Link>
|
||||
{includeParent && <Parent item={item} rootText={rootText} />}
|
||||
{me && !item.meSats && !item.meDontLike && !item.mine && <DontLikeThis id={item.id} />}
|
||||
{(item.outlawed && <Link href='/outlawed'><a>{' '}<Badge className={itemStyles.newComment} variant={null}>OUTLAWED</Badge></a></Link>) ||
|
||||
(item.freebie && !item.mine && (me?.greeterMode) && <Link href='/freebie'><a>{' '}<Badge className={itemStyles.newComment} variant={null}>FREEBIE</Badge></a></Link>)}
|
||||
{canEdit &&
|
||||
<>
|
||||
<span> \ </span>
|
||||
|
|
|
@ -8,6 +8,14 @@
|
|||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.dontLike {
|
||||
fill: #a5a5a5;
|
||||
margin-right: .2rem;
|
||||
padding: 2px;
|
||||
margin-left: 1px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-top: .1rem;
|
||||
padding-right: 15px;
|
||||
|
|
|
@ -6,7 +6,6 @@ import TextareaAutosize from 'react-textarea-autosize'
|
|||
import Countdown from './countdown'
|
||||
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
|
||||
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
||||
import { useState } from 'react'
|
||||
import FeeButton, { EditFeeButton } from './fee-button'
|
||||
|
||||
export function DiscussionForm ({
|
||||
|
@ -16,7 +15,6 @@ export function DiscussionForm ({
|
|||
}) {
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
const [hasImgLink, setHasImgLink] = useState()
|
||||
// const me = useMe()
|
||||
const [upsertDiscussion] = useMutation(
|
||||
gql`
|
||||
|
@ -77,17 +75,16 @@ export function DiscussionForm ({
|
|||
hint={editThreshold
|
||||
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
|
||||
: null}
|
||||
setHasImgLink={setHasImgLink}
|
||||
/>
|
||||
{adv && <AdvPostForm edit={!!item} />}
|
||||
<div className='mt-3'>
|
||||
{item
|
||||
? <EditFeeButton
|
||||
paidSats={item.meSats} hadImgLink={item.paidImgLink} hasImgLink={hasImgLink}
|
||||
paidSats={item.meSats}
|
||||
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
|
||||
/>
|
||||
: <FeeButton
|
||||
baseFee={1} hasImgLink={hasImgLink} parentId={null} text={buttonText}
|
||||
baseFee={1} parentId={null} text={buttonText}
|
||||
ChildButton={SubmitButton} variant='secondary'
|
||||
/>}
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import { gql, useMutation } from '@apollo/client'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import MoreIcon from '../svgs/more-fill.svg'
|
||||
import { useFundError } from './fund-error'
|
||||
|
||||
export default function DontLikeThis ({ id }) {
|
||||
const { setError } = useFundError()
|
||||
|
||||
const [dontLikeThis] = useMutation(
|
||||
gql`
|
||||
mutation dontLikeThis($id: ID!) {
|
||||
dontLikeThis(id: $id)
|
||||
}`, {
|
||||
update (cache) {
|
||||
cache.modify({
|
||||
id: `Item:${id}`,
|
||||
fields: {
|
||||
meDontLike () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Dropdown className='pointer' as='span'>
|
||||
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
|
||||
<MoreIcon className='fill-grey ml-1' height={16} width={16} />
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
className='text-center'
|
||||
onClick={async () => {
|
||||
try {
|
||||
await dontLikeThis({
|
||||
variables: { id },
|
||||
optimisticResponse: { dontLikeThis: true }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.toString().includes('insufficient funds')) {
|
||||
setError(true)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
flag
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
|
@ -14,6 +14,7 @@ import { randInRange } from '../lib/rand'
|
|||
import { formatSats } from '../lib/format'
|
||||
import NoteIcon from '../svgs/notification-4-fill.svg'
|
||||
import { useQuery, gql } from '@apollo/client'
|
||||
import LightningIcon from '../svgs/bolt.svg'
|
||||
|
||||
function WalletSummary ({ me }) {
|
||||
if (!me) return null
|
||||
|
@ -125,7 +126,18 @@ export default function Header ({ sub }) {
|
|||
setFired(true)
|
||||
}, [router.asPath])
|
||||
}
|
||||
return path !== '/login' && !path.startsWith('/invites') && <Button id='login' onClick={signIn}>login</Button>
|
||||
return path !== '/login' && !path.startsWith('/invites') &&
|
||||
<Button
|
||||
className='align-items-center d-flex pl-2 pr-3'
|
||||
id='login'
|
||||
onClick={() => signIn(null, { callbackUrl: window.location.origin + router.asPath })}
|
||||
>
|
||||
<LightningIcon
|
||||
width={17}
|
||||
height={17}
|
||||
className='mr-1'
|
||||
/>login
|
||||
</Button>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -83,7 +83,7 @@ function ItemEmbed ({ item }) {
|
|||
}
|
||||
|
||||
function TopLevelItem ({ item, noReply, ...props }) {
|
||||
const ItemComponent = item.maxBid ? ItemJob : Item
|
||||
const ItemComponent = item.isJob ? ItemJob : Item
|
||||
|
||||
return (
|
||||
<ItemComponent item={item} toc showFwdUser {...props}>
|
||||
|
|
|
@ -18,7 +18,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||
{rank}
|
||||
</div>)
|
||||
: <div />}
|
||||
<div className={`${styles.item} ${item.status === 'NOSATS' && !item.mine ? styles.itemDead : ''}`}>
|
||||
<div className={`${styles.item}`}>
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a>
|
||||
<Image
|
||||
|
@ -38,11 +38,6 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||
</Link>
|
||||
</div>
|
||||
<div className={`${styles.other}`}>
|
||||
{item.status === 'NOSATS' &&
|
||||
<>
|
||||
<span>expired</span>
|
||||
{item.company && <span> \ </span>}
|
||||
</>}
|
||||
{item.company &&
|
||||
<>
|
||||
{item.company}
|
||||
|
@ -72,7 +67,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||
edit
|
||||
</a>
|
||||
</Link>
|
||||
{item.status !== 'ACTIVE' && <span className='font-weight-bold text-danger'> {item.status}</span>}
|
||||
{item.status !== 'ACTIVE' && <span className='ml-1 font-weight-bold text-boost'> {item.status}</span>}
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -11,6 +11,9 @@ import Toc from './table-of-contents'
|
|||
import PollIcon from '../svgs/bar-chart-horizontal-fill.svg'
|
||||
import { Badge } from 'react-bootstrap'
|
||||
import { newComments } from '../lib/new-comments'
|
||||
import { useMe } from './me'
|
||||
import DontLikeThis from './dont-link-this'
|
||||
import Flag from '../svgs/flag-fill.svg'
|
||||
|
||||
export function SearchTitle ({ title }) {
|
||||
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
|
||||
|
@ -36,6 +39,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
|
|||
useState(mine && (Date.now() < editThreshold))
|
||||
const [wrap, setWrap] = useState(false)
|
||||
const titleRef = useRef()
|
||||
const me = useMe()
|
||||
const [hasNewComments, setHasNewComments] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -58,7 +62,9 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
|
|||
</div>)
|
||||
: <div />}
|
||||
<div className={styles.item}>
|
||||
{item.position ? <Pin width={24} height={24} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
|
||||
{item.position
|
||||
? <Pin width={24} height={24} className={styles.pin} />
|
||||
: item.meDontLike ? <Flag width={24} height={24} className={`${styles.dontLike}`} /> : <UpVote item={item} className={styles.upvote} />}
|
||||
<div className={styles.hunk}>
|
||||
<div className={`${styles.main} flex-wrap ${wrap ? 'd-inline' : ''}`}>
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
|
@ -104,6 +110,9 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
|
|||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
|
||||
</Link>
|
||||
{me && !item.meSats && !item.position && !item.meDontLike && !item.mine && <DontLikeThis id={item.id} />}
|
||||
{(item.outlawed && <Link href='/outlawed'><a>{' '}<Badge className={styles.newComment} variant={null}>OUTLAWED</Badge></a></Link>) ||
|
||||
(item.freebie && !item.mine && (me?.greeterMode) && <Link href='/freebie'><a>{' '}<Badge className={styles.newComment} variant={null}>FREEBIE</Badge></a></Link>)}
|
||||
{item.prior &&
|
||||
<>
|
||||
<span> \ </span>
|
||||
|
|
|
@ -23,6 +23,7 @@ a.title:visited {
|
|||
.newComment {
|
||||
color: var(--theme-grey) !important;
|
||||
background: var(--theme-clickToContextColor) !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.pin {
|
||||
|
@ -30,6 +31,13 @@ a.title:visited {
|
|||
margin-right: .2rem;
|
||||
}
|
||||
|
||||
.dontLike {
|
||||
fill: #a5a5a5;
|
||||
margin-right: .2rem;
|
||||
padding: 2px;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.case {
|
||||
fill: #a5a5a5;
|
||||
margin-right: .2rem;
|
||||
|
@ -76,7 +84,7 @@ a.link:visited {
|
|||
}
|
||||
|
||||
.hunk {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
line-height: 1.06rem;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
import { useRouter } from 'next/router'
|
||||
import React from 'react'
|
||||
import { ignoreClick } from '../lib/clicks'
|
||||
import Comment from './comment'
|
||||
import Item from './item'
|
||||
import ItemJob from './item-job'
|
||||
import { ItemsSkeleton } from './items'
|
||||
import styles from './items.module.css'
|
||||
import MoreFooter from './more-footer'
|
||||
|
||||
export default function MixedItems ({ rank, items, cursor, fetchMore }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{item.parentId
|
||||
? (
|
||||
<><div />
|
||||
<div
|
||||
className='pb-1 mb-1 clickToContext' onClick={e => {
|
||||
if (ignoreClick(e)) {
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
pathname: '/items/[id]',
|
||||
query: { id: item.root.id, commentId: item.id }
|
||||
}, `/items/${item.root.id}`)
|
||||
}}
|
||||
>
|
||||
<Comment item={item} noReply includeParent clickToContext />
|
||||
</div>
|
||||
</>)
|
||||
: (item.isJob
|
||||
? <ItemJob item={item} rank={rank && i + 1} />
|
||||
: <Item item={item} rank={rank && i + 1} />)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<MoreFooter
|
||||
cursor={cursor} fetchMore={fetchMore}
|
||||
Skeleton={() => <ItemsSkeleton rank={rank} startRank={items.length} />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -28,9 +28,14 @@ export default function Items ({ variables = {}, rank, items, pins, cursor }) {
|
|||
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />}
|
||||
{item.parentId
|
||||
? <><div /><div className='pb-3'><Comment item={item} noReply includeParent /></div></>
|
||||
: (item.maxBid
|
||||
: (item.isJob
|
||||
? <ItemJob item={item} rank={rank && i + 1} />
|
||||
: <Item item={item} rank={rank && i + 1} />)}
|
||||
: (item.title
|
||||
? <Item item={item} rank={rank && i + 1} />
|
||||
: (
|
||||
<div className='pb-2'>
|
||||
<Comment item={item} noReply includeParent clickToContext />
|
||||
</div>)))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
@ -42,7 +47,7 @@ export default function Items ({ variables = {}, rank, items, pins, cursor }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ItemsSkeleton ({ rank, startRank = 0 }) {
|
||||
export function ItemsSkeleton ({ rank, startRank = 0 }) {
|
||||
const items = new Array(21).fill(null)
|
||||
|
||||
return (
|
||||
|
|
|
@ -11,6 +11,8 @@ import { useRouter } from 'next/router'
|
|||
import Link from 'next/link'
|
||||
import { CURRENCY_SYMBOLS, usePrice } from './price'
|
||||
import Avatar from './avatar'
|
||||
import BootstrapForm from 'react-bootstrap/Form'
|
||||
import Alert from 'react-bootstrap/Alert'
|
||||
|
||||
Yup.addMethod(Yup.string, 'or', function (schemas, msg) {
|
||||
return this.test({
|
||||
|
@ -37,7 +39,7 @@ function PriceHint ({ monthly }) {
|
|||
const { fiatCurrency } = useMe();
|
||||
const fiatSymbol = CURRENCY_SYMBOLS[fiatCurrency]
|
||||
|
||||
if (!price) {
|
||||
if (!price || !monthly) {
|
||||
return null
|
||||
}
|
||||
const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
|
||||
|
@ -50,13 +52,7 @@ function PriceHint ({ monthly }) {
|
|||
export default function JobForm ({ item, sub }) {
|
||||
const storageKeyPrefix = item ? undefined : `${sub.name}-job`
|
||||
const router = useRouter()
|
||||
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || sub.baseCost))
|
||||
const [logoId, setLogoId] = useState(item?.uploadId)
|
||||
const [getAuctionPosition, { data }] = useLazyQuery(gql`
|
||||
query AuctionPosition($id: ID, $bid: Int!) {
|
||||
auctionPosition(sub: "${sub.name}", id: $id, bid: $bid)
|
||||
}`,
|
||||
{ fetchPolicy: 'network-only' })
|
||||
const [upsertJob] = useMutation(gql`
|
||||
mutation upsertJob($id: ID, $title: String!, $company: String!, $location: String,
|
||||
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int) {
|
||||
|
@ -75,8 +71,8 @@ export default function JobForm ({ item, sub }) {
|
|||
url: Yup.string()
|
||||
.or([Yup.string().email(), Yup.string().url()], 'invalid url or email')
|
||||
.required('required'),
|
||||
maxBid: Yup.number('must be number')
|
||||
.integer('must be whole').min(sub.baseCost, `must be at least ${sub.baseCost}`)
|
||||
maxBid: Yup.number().typeError('must be a number')
|
||||
.integer('must be whole').min(0, 'must be positive')
|
||||
.required('required'),
|
||||
location: Yup.string().test(
|
||||
'no-remote',
|
||||
|
@ -88,14 +84,6 @@ export default function JobForm ({ item, sub }) {
|
|||
})
|
||||
})
|
||||
|
||||
const position = data?.auctionPosition
|
||||
|
||||
useEffect(() => {
|
||||
const initialMaxBid = Number(item?.maxBid || localStorage.getItem(storageKeyPrefix + '-maxBid')) || sub.baseCost
|
||||
getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
|
||||
setMonthly(satsMin2Mo(initialMaxBid))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
|
@ -107,7 +95,7 @@ export default function JobForm ({ item, sub }) {
|
|||
remote: item?.remote || false,
|
||||
text: item?.text || '',
|
||||
url: item?.url || '',
|
||||
maxBid: item?.maxBid || sub.baseCost,
|
||||
maxBid: item?.maxBid || 0,
|
||||
stop: false,
|
||||
start: false
|
||||
}}
|
||||
|
@ -191,36 +179,66 @@ export default function JobForm ({ item, sub }) {
|
|||
required
|
||||
clear
|
||||
/>
|
||||
<PromoteJob item={item} sub={sub} storageKeyPrefix={storageKeyPrefix} />
|
||||
{item && <StatusControl item={item} />}
|
||||
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PromoteJob ({ item, sub, storageKeyPrefix }) {
|
||||
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0))
|
||||
const [getAuctionPosition, { data }] = useLazyQuery(gql`
|
||||
query AuctionPosition($id: ID, $bid: Int!) {
|
||||
auctionPosition(sub: "${sub.name}", id: $id, bid: $bid)
|
||||
}`,
|
||||
{ fetchPolicy: 'network-only' })
|
||||
const position = data?.auctionPosition
|
||||
|
||||
useEffect(() => {
|
||||
const initialMaxBid = Number(item?.maxBid || localStorage.getItem(storageKeyPrefix + '-maxBid')) || 0
|
||||
getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
|
||||
setMonthly(satsMin2Mo(initialMaxBid))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AccordianItem
|
||||
show={item?.maxBid > 0}
|
||||
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>}
|
||||
body={
|
||||
<>
|
||||
<Input
|
||||
label={
|
||||
<div className='d-flex align-items-center'>bid
|
||||
<Info>
|
||||
<ol className='font-weight-bold'>
|
||||
<li>The higher your bid the higher your job will rank</li>
|
||||
<li>The minimum bid is {sub.baseCost} sats/min</li>
|
||||
<li>You can increase or decrease your bid, and edit or stop your job at anytime</li>
|
||||
<li>Your job will be hidden if your wallet runs out of sats and can be unhidden by filling your wallet again</li>
|
||||
<li>You can increase, decrease, or remove your bid at anytime</li>
|
||||
<li>You can edit or stop your job at anytime</li>
|
||||
<li>If you run out of sats, your job will stop being promoted until you fill your wallet again</li>
|
||||
</ol>
|
||||
</Info>
|
||||
<small className='text-muted ml-2'>optional</small>
|
||||
</div>
|
||||
}
|
||||
name='maxBid'
|
||||
onChange={async (formik, e) => {
|
||||
if (e.target.value >= sub.baseCost && e.target.value <= 100000000) {
|
||||
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(sub.baseCost))
|
||||
setMonthly(satsMin2Mo(0))
|
||||
}
|
||||
}}
|
||||
append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>}
|
||||
hint={<PriceHint monthly={monthly} />}
|
||||
storageKeyPrefix={storageKeyPrefix}
|
||||
/>
|
||||
<><div className='font-weight-bold text-muted'>This bid puts your job in position: {position}</div></>
|
||||
{item && <StatusControl item={item} />}
|
||||
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
|
||||
</Form>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -244,7 +262,7 @@ function StatusControl ({ item }) {
|
|||
</>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
} else if (item.status === 'STOPPED') {
|
||||
StatusComp = () => {
|
||||
return (
|
||||
<AccordianItem
|
||||
|
@ -261,12 +279,13 @@ function StatusControl ({ item }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='my-2'>
|
||||
<div className='my-3 border border-3 rounded'>
|
||||
<div className='p-3'>
|
||||
<BootstrapForm.Label>job control</BootstrapForm.Label>
|
||||
{item.status === 'NOSATS' &&
|
||||
<div className='text-danger font-weight-bold my-1'>
|
||||
you have no sats! <Link href='/wallet?type=fund' passHref><a className='text-reset text-underline'>fund your wallet</a></Link> to resume your job
|
||||
</div>}
|
||||
<Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' passHref><a className='text-reset text-underline'>fund your wallet</a></Link> or reduce bid to continue promoting your job</Alert>}
|
||||
<StatusComp />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -74,8 +74,14 @@ function Notification ({ n }) {
|
|||
<HandCoin className='align-self-center fill-boost mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} />
|
||||
<div className='ml-2'>
|
||||
<div className='font-weight-bold text-boost'>
|
||||
you stacked {n.earnedSats} sats <small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||
you stacked {n.earnedSats} sats in rewards<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||
</div>
|
||||
{n.sources &&
|
||||
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
|
||||
{n.sources.posts > 0 && <span>{n.sources.posts} sats for top posts</span>}
|
||||
{n.sources.comments > 0 && <span>{n.sources.posts > 0 && ' \\ '}{n.sources.comments} sats for top comments</span>}
|
||||
{n.sources.tips > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0) && ' \\ '}{n.sources.tips} sats for tipping top content early</span>}
|
||||
</div>}
|
||||
<div className='pb-1' style={{ lineHeight: '140%' }}>
|
||||
SN distributes the sats it earns back to its best users daily. These sats come from <Link href='/~jobs' passHref><a>jobs</a></Link>, boost, and posting fees.
|
||||
</div>
|
||||
|
@ -99,13 +105,15 @@ function Notification ({ n }) {
|
|||
you were mentioned in
|
||||
</small>}
|
||||
{n.__typename === 'JobChanged' &&
|
||||
<small className={`font-weight-bold text-${n.item.status === 'NOSATS' ? 'danger' : 'success'} ml-1`}>
|
||||
{n.item.status === 'NOSATS'
|
||||
? 'your job ran out of sats'
|
||||
: 'your job is active again'}
|
||||
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
|
||||
{n.item.status === 'ACTIVE'
|
||||
? 'your job is active again'
|
||||
: (n.item.status === 'NOSATS'
|
||||
? 'your job promotion ran out of sats'
|
||||
: 'your job has been stopped')}
|
||||
</small>}
|
||||
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}>
|
||||
{n.item.maxBid
|
||||
{n.item.isJob
|
||||
? <ItemJob item={n.item} />
|
||||
: n.item.title
|
||||
? <Item item={n.item} />
|
||||
|
|
|
@ -6,13 +6,11 @@ import Countdown from './countdown'
|
|||
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
|
||||
import { MAX_TITLE_LENGTH, MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES } from '../lib/constants'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import { useState } from 'react'
|
||||
import FeeButton, { EditFeeButton } from './fee-button'
|
||||
|
||||
export function PollForm ({ item, editThreshold }) {
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
const [hasImgLink, setHasImgLink] = useState()
|
||||
|
||||
const [upsertPoll] = useMutation(
|
||||
gql`
|
||||
|
@ -82,7 +80,6 @@ export function PollForm ({ item, editThreshold }) {
|
|||
name='text'
|
||||
as={TextareaAutosize}
|
||||
minRows={2}
|
||||
setHasImgLink={setHasImgLink}
|
||||
/>
|
||||
<VariableInput
|
||||
label='choices'
|
||||
|
@ -97,11 +94,11 @@ export function PollForm ({ item, editThreshold }) {
|
|||
<div className='mt-3'>
|
||||
{item
|
||||
? <EditFeeButton
|
||||
paidSats={item.meSats} hadImgLink={item.paidImgLink} hasImgLink={hasImgLink}
|
||||
paidSats={item.meSats}
|
||||
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
|
||||
/>
|
||||
: <FeeButton
|
||||
baseFee={1} hasImgLink={hasImgLink} parentId={null} text='post'
|
||||
baseFee={1} parentId={null} text='post'
|
||||
ChildButton={SubmitButton} variant='secondary'
|
||||
/>}
|
||||
</div>
|
||||
|
|
|
@ -25,7 +25,6 @@ export function ReplyOnAnotherPage ({ parentId }) {
|
|||
export default function Reply ({ item, onSuccess, replyOpen }) {
|
||||
const [reply, setReply] = useState(replyOpen)
|
||||
const me = useMe()
|
||||
const [hasImgLink, setHasImgLink] = useState()
|
||||
const parentId = item.id
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -104,7 +103,6 @@ export default function Reply ({ item, onSuccess, replyOpen }) {
|
|||
}
|
||||
resetForm({ text: '' })
|
||||
setReply(replyOpen || false)
|
||||
setHasImgLink(false)
|
||||
}}
|
||||
storageKeyPrefix={'reply-' + parentId}
|
||||
>
|
||||
|
@ -114,13 +112,12 @@ export default function Reply ({ item, onSuccess, replyOpen }) {
|
|||
minRows={6}
|
||||
autoFocus={!replyOpen}
|
||||
required
|
||||
setHasImgLink={setHasImgLink}
|
||||
hint={me?.freeComments ? <span className='text-success'>{me.freeComments} free comments left</span> : null}
|
||||
/>
|
||||
{reply &&
|
||||
<div className='mt-1'>
|
||||
<FeeButton
|
||||
baseFee={1} hasImgLink={hasImgLink} parentId={parentId} text='reply'
|
||||
baseFee={1} parentId={parentId} text='reply'
|
||||
ChildButton={SubmitButton} variant='secondary' alwaysShow
|
||||
/>
|
||||
</div>}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Button, Container } from 'react-bootstrap'
|
||||
import styles from './search.module.css'
|
||||
import SearchIcon from '../svgs/search-fill.svg'
|
||||
import SearchIcon from '../svgs/search-line.svg'
|
||||
import CloseIcon from '../svgs/close-line.svg'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Form, Input, SubmitButton } from './form'
|
||||
|
|
|
@ -14,10 +14,12 @@ export const COMMENT_FIELDS = gql`
|
|||
upvotes
|
||||
boost
|
||||
meSats
|
||||
meDontLike
|
||||
outlawed
|
||||
freebie
|
||||
path
|
||||
commentSats
|
||||
mine
|
||||
paidImgLink
|
||||
ncomments
|
||||
root {
|
||||
id
|
||||
|
|
|
@ -21,10 +21,14 @@ export const ITEM_FIELDS = gql`
|
|||
boost
|
||||
path
|
||||
meSats
|
||||
meDontLike
|
||||
outlawed
|
||||
freebie
|
||||
ncomments
|
||||
commentSats
|
||||
lastCommentAt
|
||||
maxBid
|
||||
isJob
|
||||
company
|
||||
location
|
||||
remote
|
||||
|
@ -36,7 +40,6 @@ export const ITEM_FIELDS = gql`
|
|||
status
|
||||
uploadId
|
||||
mine
|
||||
paidImgLink
|
||||
root {
|
||||
id
|
||||
title
|
||||
|
@ -67,6 +70,45 @@ export const ITEMS = gql`
|
|||
}
|
||||
}`
|
||||
|
||||
export const OUTLAWED_ITEMS = gql`
|
||||
${ITEM_FIELDS}
|
||||
|
||||
query outlawedItems($cursor: String) {
|
||||
outlawedItems(cursor: $cursor) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
text
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const BORDERLAND_ITEMS = gql`
|
||||
${ITEM_FIELDS}
|
||||
|
||||
query borderlandItems($cursor: String) {
|
||||
borderlandItems(cursor: $cursor) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
text
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const FREEBIE_ITEMS = gql`
|
||||
${ITEM_FIELDS}
|
||||
|
||||
query freebieItems($cursor: String) {
|
||||
freebieItems(cursor: $cursor) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
text
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const POLL_FIELDS = gql`
|
||||
fragment PollFields on Item {
|
||||
poll {
|
||||
|
|
|
@ -31,6 +31,11 @@ export const NOTIFICATIONS = gql`
|
|||
... on Earn {
|
||||
sortTime
|
||||
earnedSats
|
||||
sources {
|
||||
posts
|
||||
comments
|
||||
tips
|
||||
}
|
||||
}
|
||||
... on Reply {
|
||||
sortTime
|
||||
|
|
|
@ -26,6 +26,8 @@ export const ME = gql`
|
|||
noteInvites
|
||||
noteJobIndicator
|
||||
hideInvoiceDesc
|
||||
wildWestMode
|
||||
greeterMode
|
||||
lastCheckedJobs
|
||||
}
|
||||
}`
|
||||
|
@ -52,6 +54,8 @@ export const ME_SSR = gql`
|
|||
noteInvites
|
||||
noteJobIndicator
|
||||
hideInvoiceDesc
|
||||
wildWestMode
|
||||
greeterMode
|
||||
lastCheckedJobs
|
||||
}
|
||||
}`
|
||||
|
@ -68,6 +72,8 @@ export const SETTINGS_FIELDS = gql`
|
|||
noteInvites
|
||||
noteJobIndicator
|
||||
hideInvoiceDesc
|
||||
wildWestMode
|
||||
greeterMode
|
||||
authMethods {
|
||||
lightning
|
||||
email
|
||||
|
@ -89,11 +95,13 @@ gql`
|
|||
${SETTINGS_FIELDS}
|
||||
mutation setSettings($tipDefault: Int!, $fiatCurrency: String!, $noteItemSats: Boolean!, $noteEarning: Boolean!,
|
||||
$noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
|
||||
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!) {
|
||||
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!,
|
||||
$wildWestMode: Boolean!, $greeterMode: Boolean!) {
|
||||
setSettings(tipDefault: $tipDefault, fiatCurrency: $fiatCurrency, noteItemSats: $noteItemSats,
|
||||
noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
|
||||
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
|
||||
noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc) {
|
||||
noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, wildWestMode: $wildWestMode,
|
||||
greeterMode: $greeterMode) {
|
||||
...SettingsFields
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,6 +52,45 @@ export default function getApolloClient () {
|
|||
}
|
||||
}
|
||||
},
|
||||
outlawedItems: {
|
||||
keyArgs: [],
|
||||
merge (existing, incoming) {
|
||||
if (isFirstPage(incoming.cursor, existing?.items)) {
|
||||
return incoming
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: incoming.cursor,
|
||||
items: [...(existing?.items || []), ...incoming.items]
|
||||
}
|
||||
}
|
||||
},
|
||||
borderlandItems: {
|
||||
keyArgs: [],
|
||||
merge (existing, incoming) {
|
||||
if (isFirstPage(incoming.cursor, existing?.items)) {
|
||||
return incoming
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: incoming.cursor,
|
||||
items: [...(existing?.items || []), ...incoming.items]
|
||||
}
|
||||
}
|
||||
},
|
||||
freebieItems: {
|
||||
keyArgs: [],
|
||||
merge (existing, incoming) {
|
||||
if (isFirstPage(incoming.cursor, existing?.items)) {
|
||||
return incoming
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: incoming.cursor,
|
||||
items: [...(existing?.items || []), ...incoming.items]
|
||||
}
|
||||
}
|
||||
},
|
||||
search: {
|
||||
keyArgs: ['q'],
|
||||
merge (existing, incoming) {
|
||||
|
|
|
@ -14,3 +14,5 @@ export const MAX_TITLE_LENGTH = 80
|
|||
export const MAX_POLL_CHOICE_LENGTH = 30
|
||||
export const ITEM_SPAM_INTERVAL = '10m'
|
||||
export const MAX_POLL_NUM_CHOICES = 10
|
||||
export const ITEM_FILTER_THRESHOLD = 1.2
|
||||
export const DONT_LIKE_THIS_COST = 1
|
||||
|
|
|
@ -23,8 +23,6 @@ const BioSchema = Yup.object({
|
|||
})
|
||||
|
||||
export function BioForm ({ handleSuccess, bio }) {
|
||||
const [hasImgLink, setHasImgLink] = useState()
|
||||
|
||||
const [upsertBio] = useMutation(
|
||||
gql`
|
||||
${ITEM_FIELDS}
|
||||
|
@ -70,16 +68,15 @@ export function BioForm ({ handleSuccess, bio }) {
|
|||
name='bio'
|
||||
as={TextareaAutosize}
|
||||
minRows={6}
|
||||
setHasImgLink={setHasImgLink}
|
||||
/>
|
||||
<div className='mt-3'>
|
||||
{bio?.text
|
||||
? <EditFeeButton
|
||||
paidSats={bio?.meSats} hadImgLink={bio?.paidImgLink} hasImgLink={hasImgLink}
|
||||
paidSats={bio?.meSats}
|
||||
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
|
||||
/>
|
||||
: <FeeButton
|
||||
baseFee={1} hasImgLink={hasImgLink} parentId={null} text='create'
|
||||
baseFee={1} parentId={null} text='create'
|
||||
ChildButton={SubmitButton} variant='secondary'
|
||||
/>}
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import Layout from '../components/layout'
|
||||
import { ItemsSkeleton } from '../components/items'
|
||||
import { getGetServerSideProps } from '../api/ssrApollo'
|
||||
import { BORDERLAND_ITEMS } from '../fragments/items'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import MixedItems from '../components/items-mixed'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps(BORDERLAND_ITEMS)
|
||||
|
||||
export default function Index ({ data: { borderlandItems: { items, cursor } } }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Items
|
||||
items={items} cursor={cursor}
|
||||
/>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
function Items ({ rank, items, cursor }) {
|
||||
const { data, fetchMore } = useQuery(BORDERLAND_ITEMS)
|
||||
|
||||
if (!data && !items) {
|
||||
return <ItemsSkeleton rank={rank} />
|
||||
}
|
||||
|
||||
if (data) {
|
||||
({ borderlandItems: { items, cursor } } = data)
|
||||
}
|
||||
|
||||
return <MixedItems items={items} cursor={cursor} rank={rank} fetchMore={fetchMore} />
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import Layout from '../components/layout'
|
||||
import { ItemsSkeleton } from '../components/items'
|
||||
import { getGetServerSideProps } from '../api/ssrApollo'
|
||||
import { FREEBIE_ITEMS } from '../fragments/items'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import MixedItems from '../components/items-mixed'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps(FREEBIE_ITEMS)
|
||||
|
||||
export default function Index ({ data: { freebieItems: { items, cursor } } }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Items
|
||||
items={items} cursor={cursor}
|
||||
/>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
function Items ({ rank, items, cursor }) {
|
||||
const { data, fetchMore } = useQuery(FREEBIE_ITEMS)
|
||||
|
||||
if (!data && !items) {
|
||||
return <ItemsSkeleton rank={rank} />
|
||||
}
|
||||
|
||||
if (data) {
|
||||
({ freebieItems: { items, cursor } } = data)
|
||||
}
|
||||
|
||||
return <MixedItems items={items} cursor={cursor} rank={rank} fetchMore={fetchMore} />
|
||||
}
|
|
@ -14,7 +14,7 @@ export default function PostEdit ({ data: { item } }) {
|
|||
|
||||
return (
|
||||
<LayoutCenter sub={item.sub?.name}>
|
||||
{item.maxBid
|
||||
{item.isJob
|
||||
? <JobForm item={item} sub={item.sub} />
|
||||
: (item.url
|
||||
? <LinkForm item={item} editThreshold={editThreshold} adv />
|
||||
|
|
|
@ -6,7 +6,7 @@ import { getGetServerSideProps } from '../../../api/ssrApollo'
|
|||
import { useQuery } from '@apollo/client'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps(ITEM_FULL, null,
|
||||
data => !data.item || (data.item.status !== 'ACTIVE' && !data.item.mine))
|
||||
data => !data.item || (data.item.status === 'STOPPED' && !data.item.mine))
|
||||
|
||||
export default function AnItem ({ data: { item } }) {
|
||||
const { data } = useQuery(ITEM_FULL, {
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import Layout from '../components/layout'
|
||||
import { ItemsSkeleton } from '../components/items'
|
||||
import { getGetServerSideProps } from '../api/ssrApollo'
|
||||
import { OUTLAWED_ITEMS } from '../fragments/items'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import MixedItems from '../components/items-mixed'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps(OUTLAWED_ITEMS)
|
||||
|
||||
export default function Index ({ data: { outlawedItems: { items, cursor } } }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Items
|
||||
items={items} cursor={cursor}
|
||||
/>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
function Items ({ rank, items, cursor }) {
|
||||
const { data, fetchMore } = useQuery(OUTLAWED_ITEMS)
|
||||
|
||||
if (!data && !items) {
|
||||
return <ItemsSkeleton rank={rank} />
|
||||
}
|
||||
|
||||
if (data) {
|
||||
({ outlawedItems: { items, cursor } } = data)
|
||||
}
|
||||
|
||||
return <MixedItems items={items} cursor={cursor} rank={rank} fetchMore={fetchMore} />
|
||||
}
|
|
@ -66,7 +66,9 @@ export default function Settings ({ data: { settings } }) {
|
|||
noteDeposits: settings?.noteDeposits,
|
||||
noteInvites: settings?.noteInvites,
|
||||
noteJobIndicator: settings?.noteJobIndicator,
|
||||
hideInvoiceDesc: settings?.hideInvoiceDesc
|
||||
hideInvoiceDesc: settings?.hideInvoiceDesc,
|
||||
wildWestMode: settings?.wildWestMode,
|
||||
greeterMode: settings?.greeterMode
|
||||
}}
|
||||
schema={SettingsSchema}
|
||||
onSubmit={async ({ tipDefault, fiatCurrency, ...values }) => {
|
||||
|
@ -126,7 +128,7 @@ export default function Settings ({ data: { settings } }) {
|
|||
<div className='form-label'>privacy</div>
|
||||
<Checkbox
|
||||
label={
|
||||
<>hide invoice descriptions
|
||||
<div className='d-flex align-items-center'>hide invoice descriptions
|
||||
<Info>
|
||||
<ul className='font-weight-bold'>
|
||||
<li>Use this if you don't want funding sources to be linkable to your SN identity.</li>
|
||||
|
@ -138,10 +140,39 @@ export default function Settings ({ data: { settings } }) {
|
|||
</li>
|
||||
</ul>
|
||||
</Info>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
name='hideInvoiceDesc'
|
||||
/>
|
||||
<div className='form-label'>content</div>
|
||||
<Checkbox
|
||||
label={
|
||||
<div className='d-flex align-items-center'>wild west mode
|
||||
<Info>
|
||||
<ul className='font-weight-bold'>
|
||||
<li>don't hide flagged content</li>
|
||||
<li>don't down rank flagged content</li>
|
||||
</ul>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='wildWestMode'
|
||||
groupClassName='mb-0'
|
||||
/>
|
||||
<Checkbox
|
||||
label={
|
||||
<div className='d-flex align-items-center'>greeter mode
|
||||
<Info>
|
||||
<ul className='font-weight-bold'>
|
||||
<li>see and screen free posts and comments</li>
|
||||
<li>help onboard users to SN and Lightning</li>
|
||||
<li>you might be subject to more spam</li>
|
||||
</ul>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='greeterMode'
|
||||
/>
|
||||
<div className='d-flex'>
|
||||
<SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "EarnType" AS ENUM ('POST', 'COMMENT', 'TIP_COMMENT', 'TIP_POST');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Earn" ADD COLUMN "rank" INTEGER,
|
||||
ADD COLUMN "type" "EarnType",
|
||||
ADD COLUMN "typeId" INTEGER;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Earn.created_at_userId_index" ON "Earn"("created_at", "userId");
|
|
@ -0,0 +1,16 @@
|
|||
CREATE OR REPLACE FUNCTION earn(user_id INTEGER, earn_msats INTEGER, created_at TIMESTAMP(3),
|
||||
type "EarnType", type_id INTEGER, rank INTEGER)
|
||||
RETURNS void AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
-- insert into earn
|
||||
INSERT INTO "Earn" (msats, "userId", created_at, type, "typeId", rank)
|
||||
VALUES (earn_msats, user_id, created_at, type, type_id, rank);
|
||||
|
||||
-- give the user the sats
|
||||
UPDATE users
|
||||
SET msats = msats + earn_msats, "stackedMsats" = "stackedMsats" + earn_msats
|
||||
WHERE id = user_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
|
@ -0,0 +1,8 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "ItemActType" ADD VALUE 'DONT_LIKE_THIS';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Item" ADD COLUMN "weightedDownVotes" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "wildWestMode" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -0,0 +1,74 @@
|
|||
-- modify it to take DONT_LIKE_THIS
|
||||
CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
user_sats INTEGER;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT (msats / 1000) INTO user_sats FROM users WHERE id = user_id;
|
||||
IF act_sats > user_sats THEN
|
||||
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||
END IF;
|
||||
|
||||
-- deduct sats from actor
|
||||
UPDATE users SET msats = msats - (act_sats * 1000) WHERE id = user_id;
|
||||
|
||||
IF act = 'VOTE' OR act = 'TIP' THEN
|
||||
-- add sats to actee's balance and stacked count
|
||||
UPDATE users
|
||||
SET msats = msats + (act_sats * 1000), "stackedMsats" = "stackedMsats" + (act_sats * 1000)
|
||||
WHERE id = (SELECT COALESCE("fwdUserId", "userId") FROM "Item" WHERE id = item_id);
|
||||
|
||||
-- if they have already voted, this is a tip
|
||||
IF EXISTS (SELECT 1 FROM "ItemAct" WHERE "itemId" = item_id AND "userId" = user_id AND "ItemAct".act = 'VOTE') THEN
|
||||
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc());
|
||||
ELSE
|
||||
-- else this is a vote with a possible extra tip
|
||||
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (1, item_id, user_id, 'VOTE', now_utc(), now_utc());
|
||||
act_sats := act_sats - 1;
|
||||
|
||||
-- if we have sats left after vote, leave them as a tip
|
||||
IF act_sats > 0 THEN
|
||||
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc());
|
||||
END IF;
|
||||
|
||||
RETURN 1;
|
||||
END IF;
|
||||
ELSE -- BOOST, POLL, DONT_LIKE_THIS
|
||||
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (act_sats, item_id, user_id, act, now_utc(), now_utc());
|
||||
END IF;
|
||||
|
||||
RETURN 0;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION weighted_downvotes_after_act() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
user_trust DOUBLE PRECISION;
|
||||
BEGIN
|
||||
-- grab user's trust who is upvoting
|
||||
SELECT trust INTO user_trust FROM users WHERE id = NEW."userId";
|
||||
-- update item
|
||||
UPDATE "Item"
|
||||
SET "weightedDownVotes" = "weightedDownVotes" + user_trust
|
||||
WHERE id = NEW."itemId" AND "userId" <> NEW."userId";
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS weighted_downvotes_after_act ON "ItemAct";
|
||||
CREATE TRIGGER weighted_downvotes_after_act
|
||||
AFTER INSERT ON "ItemAct"
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.act = 'DONT_LIKE_THIS')
|
||||
EXECUTE PROCEDURE weighted_downvotes_after_act();
|
||||
|
||||
ALTER TABLE "Item" ADD CONSTRAINT "weighted_votes_positive" CHECK ("weightedVotes" >= 0) NOT VALID;
|
||||
ALTER TABLE "Item" ADD CONSTRAINT "weighted_down_votes_positive" CHECK ("weightedDownVotes" >= 0) NOT VALID;
|
|
@ -0,0 +1,64 @@
|
|||
CREATE OR REPLACE FUNCTION create_item(
|
||||
title TEXT, url TEXT, text TEXT, boost INTEGER,
|
||||
parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER,
|
||||
has_img_link BOOLEAN, spam_within INTERVAL)
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
user_msats INTEGER;
|
||||
cost INTEGER;
|
||||
free_posts INTEGER;
|
||||
free_comments INTEGER;
|
||||
freebie BOOLEAN;
|
||||
item "Item";
|
||||
med_votes INTEGER;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT msats, "freePosts", "freeComments"
|
||||
INTO user_msats, free_posts, free_comments
|
||||
FROM users WHERE id = user_id;
|
||||
|
||||
freebie := (parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 0);
|
||||
cost := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within)) * CASE WHEN has_img_link THEN 10 ELSE 1 END;
|
||||
|
||||
IF NOT freebie AND cost > user_msats THEN
|
||||
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||
END IF;
|
||||
|
||||
-- get this user's median item score
|
||||
SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP(ORDER BY "weightedVotes" - "weightedDownVotes"), 0) INTO med_votes FROM "Item" WHERE "userId" = user_id;
|
||||
|
||||
-- if their median votes are positive, start at 0
|
||||
-- if the median votes are negative, start their post with that many down votes
|
||||
-- basically: if their median post is bad, presume this post is too
|
||||
IF med_votes >= 0 THEN
|
||||
med_votes := 0;
|
||||
ELSE
|
||||
med_votes := ABS(med_votes);
|
||||
END IF;
|
||||
|
||||
INSERT INTO "Item" (title, url, text, "userId", "parentId", "fwdUserId", "paidImgLink", "weightedDownVotes", created_at, updated_at)
|
||||
VALUES (title, url, text, user_id, parent_id, fwd_user_id, has_img_link, med_votes, now_utc(), now_utc()) RETURNING * INTO item;
|
||||
|
||||
IF freebie THEN
|
||||
IF parent_id IS NULL THEN
|
||||
UPDATE users SET "freePosts" = "freePosts" - 1 WHERE id = user_id;
|
||||
ELSE
|
||||
UPDATE users SET "freeComments" = "freeComments" - 1 WHERE id = user_id;
|
||||
END IF;
|
||||
ELSE
|
||||
UPDATE users SET msats = msats - cost WHERE id = user_id;
|
||||
|
||||
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (cost / 1000, item.id, user_id, 'VOTE', now_utc(), now_utc());
|
||||
END IF;
|
||||
|
||||
IF boost > 0 THEN
|
||||
PERFORM item_act(item.id, user_id, 'BOOST', boost);
|
||||
END IF;
|
||||
|
||||
RETURN item;
|
||||
END;
|
||||
$$;
|
|
@ -0,0 +1,64 @@
|
|||
CREATE OR REPLACE FUNCTION create_item(
|
||||
title TEXT, url TEXT, text TEXT, boost INTEGER,
|
||||
parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER,
|
||||
has_img_link BOOLEAN, spam_within INTERVAL)
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
user_msats INTEGER;
|
||||
cost INTEGER;
|
||||
free_posts INTEGER;
|
||||
free_comments INTEGER;
|
||||
freebie BOOLEAN;
|
||||
item "Item";
|
||||
med_votes FLOAT;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT msats, "freePosts", "freeComments"
|
||||
INTO user_msats, free_posts, free_comments
|
||||
FROM users WHERE id = user_id;
|
||||
|
||||
freebie := (parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 0);
|
||||
cost := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within)) * CASE WHEN has_img_link THEN 10 ELSE 1 END;
|
||||
|
||||
IF NOT freebie AND cost > user_msats THEN
|
||||
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||
END IF;
|
||||
|
||||
-- get this user's median item score
|
||||
SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP(ORDER BY "weightedVotes" - "weightedDownVotes"), 0) INTO med_votes FROM "Item" WHERE "userId" = user_id;
|
||||
|
||||
-- if their median votes are positive, start at 0
|
||||
-- if the median votes are negative, start their post with that many down votes
|
||||
-- basically: if their median post is bad, presume this post is too
|
||||
IF med_votes >= 0 THEN
|
||||
med_votes := 0;
|
||||
ELSE
|
||||
med_votes := ABS(med_votes);
|
||||
END IF;
|
||||
|
||||
INSERT INTO "Item" (title, url, text, "userId", "parentId", "fwdUserId", "paidImgLink", "weightedDownVotes", created_at, updated_at)
|
||||
VALUES (title, url, text, user_id, parent_id, fwd_user_id, has_img_link, med_votes, now_utc(), now_utc()) RETURNING * INTO item;
|
||||
|
||||
IF freebie THEN
|
||||
IF parent_id IS NULL THEN
|
||||
UPDATE users SET "freePosts" = "freePosts" - 1 WHERE id = user_id;
|
||||
ELSE
|
||||
UPDATE users SET "freeComments" = "freeComments" - 1 WHERE id = user_id;
|
||||
END IF;
|
||||
ELSE
|
||||
UPDATE users SET msats = msats - cost WHERE id = user_id;
|
||||
|
||||
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (cost / 1000, item.id, user_id, 'VOTE', now_utc(), now_utc());
|
||||
END IF;
|
||||
|
||||
IF boost > 0 THEN
|
||||
PERFORM item_act(item.id, user_id, 'BOOST', boost);
|
||||
END IF;
|
||||
|
||||
RETURN item;
|
||||
END;
|
||||
$$;
|
|
@ -0,0 +1,9 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Item"
|
||||
ADD COLUMN "bio" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "freebie" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "greeterMode" BOOLEAN NOT NULL DEFAULT false,
|
||||
ALTER COLUMN "freeComments" SET DEFAULT 5,
|
||||
ALTER COLUMN "freePosts" SET DEFAULT 2;
|
|
@ -0,0 +1,172 @@
|
|||
DROP FUNCTION IF EXISTS create_bio(title TEXT, text TEXT, user_id INTEGER, has_img_link BOOLEAN);
|
||||
|
||||
-- when creating bio, set bio flag so they won't appear on first page
|
||||
CREATE OR REPLACE FUNCTION create_bio(title TEXT, text TEXT, user_id INTEGER)
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
item "Item";
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT * INTO item FROM create_item(title, NULL, text, 0, NULL, user_id, NULL, '0');
|
||||
|
||||
UPDATE "Item" SET bio = true WHERE id = item.id;
|
||||
UPDATE users SET "bioId" = item.id WHERE id = user_id;
|
||||
|
||||
RETURN item;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS create_item(
|
||||
title TEXT, url TEXT, text TEXT, boost INTEGER,
|
||||
parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER,
|
||||
has_img_link BOOLEAN, spam_within INTERVAL);
|
||||
|
||||
-- when creating free item, set freebie flag so can be optionally viewed
|
||||
CREATE OR REPLACE FUNCTION create_item(
|
||||
title TEXT, url TEXT, text TEXT, boost INTEGER,
|
||||
parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER,
|
||||
spam_within INTERVAL)
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
user_msats INTEGER;
|
||||
cost INTEGER;
|
||||
free_posts INTEGER;
|
||||
free_comments INTEGER;
|
||||
freebie BOOLEAN;
|
||||
item "Item";
|
||||
med_votes FLOAT;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT msats, "freePosts", "freeComments"
|
||||
INTO user_msats, free_posts, free_comments
|
||||
FROM users WHERE id = user_id;
|
||||
|
||||
cost := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within));
|
||||
freebie := (cost <= 1000) AND ((parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 0));
|
||||
|
||||
IF NOT freebie AND cost > user_msats THEN
|
||||
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||
END IF;
|
||||
|
||||
-- get this user's median item score
|
||||
SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP(ORDER BY "weightedVotes" - "weightedDownVotes"), 0) INTO med_votes FROM "Item" WHERE "userId" = user_id;
|
||||
|
||||
-- if their median votes are positive, start at 0
|
||||
-- if the median votes are negative, start their post with that many down votes
|
||||
-- basically: if their median post is bad, presume this post is too
|
||||
IF med_votes >= 0 THEN
|
||||
med_votes := 0;
|
||||
ELSE
|
||||
med_votes := ABS(med_votes);
|
||||
END IF;
|
||||
|
||||
INSERT INTO "Item" (title, url, text, "userId", "parentId", "fwdUserId", freebie, "weightedDownVotes", created_at, updated_at)
|
||||
VALUES (title, url, text, user_id, parent_id, fwd_user_id, freebie, med_votes, now_utc(), now_utc()) RETURNING * INTO item;
|
||||
|
||||
IF freebie THEN
|
||||
IF parent_id IS NULL THEN
|
||||
UPDATE users SET "freePosts" = "freePosts" - 1 WHERE id = user_id;
|
||||
ELSE
|
||||
UPDATE users SET "freeComments" = "freeComments" - 1 WHERE id = user_id;
|
||||
END IF;
|
||||
ELSE
|
||||
UPDATE users SET msats = msats - cost WHERE id = user_id;
|
||||
|
||||
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (cost / 1000, item.id, user_id, 'VOTE', now_utc(), now_utc());
|
||||
END IF;
|
||||
|
||||
IF boost > 0 THEN
|
||||
PERFORM item_act(item.id, user_id, 'BOOST', boost);
|
||||
END IF;
|
||||
|
||||
RETURN item;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS update_item(item_id INTEGER,
|
||||
item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER,
|
||||
fwd_user_id INTEGER, has_img_link BOOLEAN);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_item(item_id INTEGER,
|
||||
item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER,
|
||||
fwd_user_id INTEGER)
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
user_msats INTEGER;
|
||||
item "Item";
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
UPDATE "Item" set title = item_title, url = item_url, text = item_text, "fwdUserId" = fwd_user_id
|
||||
WHERE id = item_id
|
||||
RETURNING * INTO item;
|
||||
|
||||
IF boost > 0 THEN
|
||||
PERFORM item_act(item.id, item."userId", 'BOOST', boost);
|
||||
END IF;
|
||||
|
||||
RETURN item;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS create_poll(
|
||||
title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER,
|
||||
options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN, spam_within INTERVAL);
|
||||
|
||||
CREATE OR REPLACE FUNCTION create_poll(
|
||||
title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER,
|
||||
options TEXT[], fwd_user_id INTEGER, spam_within INTERVAL)
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
item "Item";
|
||||
option TEXT;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
item := create_item(title, null, text, boost, null, user_id, fwd_user_id, spam_within);
|
||||
|
||||
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
|
||||
FOREACH option IN ARRAY options LOOP
|
||||
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
|
||||
END LOOP;
|
||||
|
||||
RETURN item;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS update_poll(
|
||||
id INTEGER, title TEXT, text TEXT, boost INTEGER,
|
||||
options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_poll(
|
||||
id INTEGER, title TEXT, text TEXT, boost INTEGER,
|
||||
options TEXT[], fwd_user_id INTEGER)
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
item "Item";
|
||||
option TEXT;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
item := update_item(id, title, null, text, boost, fwd_user_id);
|
||||
|
||||
FOREACH option IN ARRAY options LOOP
|
||||
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
|
||||
END LOOP;
|
||||
|
||||
RETURN item;
|
||||
END;
|
||||
$$;
|
|
@ -0,0 +1,4 @@
|
|||
INSERT INTO "users" ("name") VALUES
|
||||
('freebie'),
|
||||
('borderland'),
|
||||
('outlawed');
|
|
@ -0,0 +1,101 @@
|
|||
-- charge the user for the auction item
|
||||
CREATE OR REPLACE FUNCTION run_auction(item_id INTEGER) RETURNS void AS $$
|
||||
DECLARE
|
||||
bid INTEGER;
|
||||
user_id INTEGER;
|
||||
user_msats INTEGER;
|
||||
item_status "Status";
|
||||
status_updated_at timestamp(3);
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
-- extract data we need
|
||||
SELECT "maxBid" * 1000, "userId", status, "statusUpdatedAt" INTO bid, user_id, item_status, status_updated_at FROM "Item" WHERE id = item_id;
|
||||
SELECT msats INTO user_msats FROM users WHERE id = user_id;
|
||||
|
||||
-- 0 bid items expire after 30 days unless updated
|
||||
IF bid = 0 THEN
|
||||
IF item_status <> 'STOPPED' AND status_updated_at < now_utc() - INTERVAL '30 days' THEN
|
||||
UPDATE "Item" SET status = 'STOPPED', "statusUpdatedAt" = now_utc() WHERE id = item_id;
|
||||
END IF;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- check if user wallet has enough sats
|
||||
IF bid > user_msats THEN
|
||||
-- if not, set status = NOSATS and statusUpdatedAt to now_utc if not already set
|
||||
IF item_status <> 'NOSATS' THEN
|
||||
UPDATE "Item" SET status = 'NOSATS', "statusUpdatedAt" = now_utc() WHERE id = item_id;
|
||||
END IF;
|
||||
ELSE
|
||||
-- if so, deduct from user
|
||||
UPDATE users SET msats = msats - bid WHERE id = user_id;
|
||||
|
||||
-- create an item act
|
||||
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (bid / 1000, item_id, user_id, 'STREAM', now_utc(), now_utc());
|
||||
|
||||
-- update item status = ACTIVE and statusUpdatedAt = now_utc if NOSATS
|
||||
IF item_status = 'NOSATS' THEN
|
||||
UPDATE "Item" SET status = 'ACTIVE', "statusUpdatedAt" = now_utc() WHERE id = item_id;
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- when creating free item, set freebie flag so can be optionally viewed
|
||||
CREATE OR REPLACE FUNCTION create_job(
|
||||
title TEXT, url TEXT, text TEXT, user_id INTEGER, job_bid INTEGER, job_company TEXT,
|
||||
job_location TEXT, job_remote BOOLEAN, job_upload_id INTEGER)
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
item "Item";
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
-- create item
|
||||
SELECT * INTO item FROM create_item(title, url, text, 0, NULL, user_id, NULL, '0');
|
||||
|
||||
-- update by adding additional fields
|
||||
UPDATE "Item"
|
||||
SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id, "subName" = 'jobs'
|
||||
WHERE id = item.id RETURNING * INTO item;
|
||||
|
||||
-- run_auction
|
||||
EXECUTE run_auction(item.id);
|
||||
|
||||
RETURN item;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_job(item_id INTEGER,
|
||||
item_title TEXT, item_url TEXT, item_text TEXT, job_bid INTEGER, job_company TEXT,
|
||||
job_location TEXT, job_remote BOOLEAN, job_upload_id INTEGER, job_status "Status")
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
user_msats INTEGER;
|
||||
item "Item";
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
-- update item
|
||||
SELECT * INTO item FROM update_item(item_id, item_title, item_url, item_text, 0, NULL);
|
||||
|
||||
IF item.status <> job_status THEN
|
||||
UPDATE "Item"
|
||||
SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id, status = job_status, "statusUpdatedAt" = now_utc()
|
||||
WHERE id = item.id RETURNING * INTO item;
|
||||
ELSE
|
||||
UPDATE "Item"
|
||||
SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id
|
||||
WHERE id = item.id RETURNING * INTO item;
|
||||
END IF;
|
||||
|
||||
-- run_auction
|
||||
EXECUTE run_auction(item.id);
|
||||
|
||||
RETURN item;
|
||||
END;
|
||||
$$;
|
|
@ -32,8 +32,8 @@ model User {
|
|||
bioId Int?
|
||||
msats Int @default(0)
|
||||
stackedMsats Int @default(0)
|
||||
freeComments Int @default(0)
|
||||
freePosts Int @default(0)
|
||||
freeComments Int @default(5)
|
||||
freePosts Int @default(2)
|
||||
checkedNotesAt DateTime?
|
||||
tipDefault Int @default(10)
|
||||
fiatCurrency String @default("USD")
|
||||
|
@ -60,6 +60,10 @@ model User {
|
|||
// privacy settings
|
||||
hideInvoiceDesc Boolean @default(false)
|
||||
|
||||
// content settings
|
||||
wildWestMode Boolean @default(false)
|
||||
greeterMode Boolean @default(false)
|
||||
|
||||
Earn Earn[]
|
||||
Upload Upload[] @relation(name: "Uploads")
|
||||
PollVote PollVote[]
|
||||
|
@ -89,6 +93,13 @@ model Upload {
|
|||
@@index([userId])
|
||||
}
|
||||
|
||||
enum EarnType {
|
||||
POST
|
||||
COMMENT
|
||||
TIP_COMMENT
|
||||
TIP_POST
|
||||
}
|
||||
|
||||
model Earn {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
|
@ -98,8 +109,13 @@ model Earn {
|
|||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
|
||||
type EarnType?
|
||||
typeId Int?
|
||||
rank Int?
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([userId])
|
||||
@@index([createdAt, userId])
|
||||
}
|
||||
|
||||
model LnAuth {
|
||||
|
@ -171,8 +187,13 @@ model Item {
|
|||
upload Upload?
|
||||
paidImgLink Boolean @default(false)
|
||||
|
||||
// is free post or bio
|
||||
freebie Boolean @default(false)
|
||||
bio Boolean @default(false)
|
||||
|
||||
// denormalized self stats
|
||||
weightedVotes Float @default(0)
|
||||
weightedDownVotes Float @default(0)
|
||||
sats Int @default(0)
|
||||
|
||||
// denormalized comment stats
|
||||
|
@ -285,6 +306,7 @@ enum ItemActType {
|
|||
TIP
|
||||
STREAM
|
||||
POLL
|
||||
DONT_LIKE_THIS
|
||||
}
|
||||
|
||||
model ItemAct {
|
||||
|
|
|
@ -217,7 +217,7 @@ a:hover {
|
|||
background-color: var(--theme-inputBg);
|
||||
border: 1px solid var(--theme-borderColor);
|
||||
max-width: 90vw;
|
||||
overflow: scroll;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M17 7a8.003 8.003 0 0 0-7.493 5.19l1.874.703A6.002 6.002 0 0 1 23 15a6 6 0 0 1-6 6H7A6 6 0 0 1 5.008 9.339a7 7 0 0 1 13.757-2.143A8.027 8.027 0 0 0 17 7z"/></svg>
|
After Width: | Height: | Size: 291 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-7v2h2v-2h-2zm0-8v6h2V7h-2z"/></svg>
|
After Width: | Height: | Size: 241 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M2 3h19.138a.5.5 0 0 1 .435.748L18 10l3.573 6.252a.5.5 0 0 1-.435.748H4v5H2V3z"/></svg>
|
After Width: | Height: | Size: 216 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 3h9.382a1 1 0 0 1 .894.553L14 5h6a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1h-6.382a1 1 0 0 1-.894-.553L12 16H5v6H3V3z"/></svg>
|
After Width: | Height: | Size: 247 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M5 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm14 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-7 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
|
After Width: | Height: | Size: 285 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z"/></svg>
|
After Width: | Height: | Size: 385 B |
|
@ -2,8 +2,7 @@ const serialize = require('../api/resolvers/serial')
|
|||
|
||||
const ITEM_EACH_REWARD = 3.0
|
||||
const UPVOTE_EACH_REWARD = 6.0
|
||||
const TOP_ITEMS = 21
|
||||
const EARLY_MULTIPLIER_MAX = 100.0
|
||||
const TOP_PERCENTILE = 21
|
||||
|
||||
// TODO: use a weekly trust measure or make trust decay
|
||||
function earn ({ models }) {
|
||||
|
@ -11,7 +10,7 @@ function earn ({ models }) {
|
|||
console.log('running', name)
|
||||
|
||||
// compute how much sn earned today
|
||||
const [{ sum }] = await models.$queryRaw`
|
||||
let [{ sum }] = await models.$queryRaw`
|
||||
SELECT sum("ItemAct".sats)
|
||||
FROM "ItemAct"
|
||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||
|
@ -19,10 +18,13 @@ function earn ({ models }) {
|
|||
OR ("ItemAct".act IN ('VOTE','POLL') AND "Item"."userId" = "ItemAct"."userId"))
|
||||
AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'`
|
||||
|
||||
// convert to msats
|
||||
sum = sum * 1000
|
||||
|
||||
/*
|
||||
How earnings work:
|
||||
1/3: top 21 posts over last 36 hours, scored on a relative basis
|
||||
1/3: top 21 comments over last 36 hours, scored on a relative basis
|
||||
1/3: top 21% posts over last 36 hours, scored on a relative basis
|
||||
1/3: top 21% comments over last 36 hours, scored on a relative basis
|
||||
1/3: top upvoters of top posts/comments, scored on:
|
||||
- their trust
|
||||
- how much they tipped
|
||||
|
@ -30,19 +32,27 @@ function earn ({ models }) {
|
|||
- how the post/comment scored
|
||||
*/
|
||||
|
||||
// get earners { id, earnings }
|
||||
if (sum <= 0) {
|
||||
console.log('done', name, 'no earning')
|
||||
return
|
||||
}
|
||||
|
||||
// get earners { userId, id, type, rank, proportion }
|
||||
const earners = await models.$queryRaw(`
|
||||
WITH item_ratios AS (
|
||||
SELECT *,
|
||||
"weightedVotes"/coalesce(NULLIF(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL),0), ${TOP_ITEMS}) AS ratio
|
||||
CASE WHEN "parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type,
|
||||
CASE WHEN "weightedVotes" > 0 THEN "weightedVotes"/(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL)) ELSE 0 END AS ratio
|
||||
FROM (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY "weightedVotes" desc) AS r
|
||||
NTILE(100) OVER (PARTITION BY "parentId" IS NULL ORDER BY "weightedVotes" desc) AS percentile,
|
||||
ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY "weightedVotes" desc) AS rank
|
||||
FROM
|
||||
"Item"
|
||||
WHERE created_at >= now_utc() - interval '36 hours'
|
||||
AND "weightedVotes" > 0
|
||||
) x
|
||||
WHERE x.r <= ${TOP_ITEMS}
|
||||
WHERE x.percentile <= ${TOP_PERCENTILE}
|
||||
),
|
||||
upvoters AS (
|
||||
SELECT "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId",
|
||||
|
@ -54,36 +64,47 @@ function earn ({ models }) {
|
|||
GROUP BY "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId"
|
||||
),
|
||||
upvoter_ratios AS (
|
||||
SELECT "userId", sum(early_multiplier*tipped_ratio*ratio*users.trust) as upvoting_score,
|
||||
"parentId" IS NULL as "isPost"
|
||||
SELECT "userId", sum(early_multiplier*tipped_ratio*ratio*users.trust) as upvoter_ratio,
|
||||
"parentId" IS NULL as "isPost", CASE WHEN "parentId" IS NULL THEN 'TIP_POST' ELSE 'TIP_COMMENT' END as type
|
||||
FROM (
|
||||
SELECT *,
|
||||
${EARLY_MULTIPLIER_MAX}/(ROW_NUMBER() OVER (partition by id order by acted_at asc)) AS early_multiplier,
|
||||
1/(ROW_NUMBER() OVER (partition by id order by acted_at asc)) AS early_multiplier,
|
||||
tipped::float/(sum(tipped) OVER (partition by id)) tipped_ratio
|
||||
FROM upvoters
|
||||
) u
|
||||
JOIN users on "userId" = users.id
|
||||
GROUP BY "userId", "parentId" IS NULL
|
||||
)
|
||||
SELECT "userId" as id, FLOOR(sum(proportion)*${sum}*1000) as earnings
|
||||
FROM (
|
||||
SELECT "userId",
|
||||
upvoting_score/(sum(upvoting_score) OVER (PARTITION BY "isPost"))/${UPVOTE_EACH_REWARD} as proportion
|
||||
SELECT "userId", NULL as id, type, ROW_NUMBER() OVER (PARTITION BY "isPost" ORDER BY upvoter_ratio DESC) as rank,
|
||||
upvoter_ratio/(sum(upvoter_ratio) OVER (PARTITION BY "isPost"))/${UPVOTE_EACH_REWARD} as proportion
|
||||
FROM upvoter_ratios
|
||||
WHERE upvoter_ratio > 0
|
||||
UNION ALL
|
||||
SELECT "userId", ratio/${ITEM_EACH_REWARD} as proportion
|
||||
FROM item_ratios
|
||||
) a
|
||||
GROUP BY "userId"
|
||||
HAVING FLOOR(sum(proportion)*${sum}) >= 1`)
|
||||
SELECT "userId", id, type, rank, ratio/${ITEM_EACH_REWARD} as proportion
|
||||
FROM item_ratios`)
|
||||
|
||||
// in order to group earnings for users we use the same createdAt time for
|
||||
// all earnings
|
||||
const now = new Date(new Date().getTime())
|
||||
|
||||
// this is just a sanity check because it seems like a good idea
|
||||
let total = 0
|
||||
|
||||
// for each earner, serialize earnings
|
||||
// we do this for each earner because we don't need to serialize
|
||||
// all earner updates together
|
||||
earners.forEach(async earner => {
|
||||
if (earner.earnings > 0) {
|
||||
const earnings = Math.floor(earner.proportion * sum)
|
||||
total += earnings
|
||||
if (total > sum) {
|
||||
console.log('total exceeds sum', name)
|
||||
return
|
||||
}
|
||||
|
||||
if (earnings > 0) {
|
||||
await serialize(models,
|
||||
models.$executeRaw`SELECT earn(${earner.id}, ${earner.earnings})`)
|
||||
models.$executeRaw`SELECT earn(${earner.userId}, ${earnings},
|
||||
${now}, ${earner.type}, ${earner.id}, ${earner.rank})`)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ function trust ({ boss, models }) {
|
|||
// only explore a path up to this depth from start
|
||||
const MAX_DEPTH = 6
|
||||
const MAX_TRUST = 0.9
|
||||
const MIN_SUCCESS = 5
|
||||
// https://en.wikipedia.org/wiki/Normal_distribution#Quantile_function
|
||||
const Z_CONFIDENCE = 2.326347874041 // 98% confidence
|
||||
|
||||
|
@ -162,39 +163,70 @@ function trustGivenGraph (graph, start) {
|
|||
// return graph
|
||||
// }
|
||||
|
||||
// upvote confidence graph
|
||||
// old upvote confidence graph
|
||||
// async function getGraph (models) {
|
||||
// const [{ graph }] = await models.$queryRaw`
|
||||
// select json_object_agg(id, hops) as graph
|
||||
// from (
|
||||
// select id, json_agg(json_build_object('node', oid, 'trust', trust)) as hops
|
||||
// from (
|
||||
// select s.id, s.oid, confidence(s.shared, count(*), ${Z_CONFIDENCE}) as trust
|
||||
// from (
|
||||
// select a."userId" as id, b."userId" as oid, count(*) as shared
|
||||
// from "ItemAct" b
|
||||
// join users bu on bu.id = b."userId"
|
||||
// join "ItemAct" a on b."itemId" = a."itemId"
|
||||
// join users au on au.id = a."userId"
|
||||
// join "Item" on "Item".id = b."itemId"
|
||||
// where b.act = 'VOTE'
|
||||
// and a.act = 'VOTE'
|
||||
// and "Item"."parentId" is null
|
||||
// and "Item"."userId" <> b."userId"
|
||||
// and "Item"."userId" <> a."userId"
|
||||
// and b."userId" <> a."userId"
|
||||
// and "Item".created_at >= au.created_at and "Item".created_at >= bu.created_at
|
||||
// group by b."userId", a."userId") s
|
||||
// join users u on s.id = u.id
|
||||
// join users ou on s.oid = ou.id
|
||||
// join "ItemAct" on "ItemAct"."userId" = s.oid
|
||||
// join "Item" on "Item".id = "ItemAct"."itemId"
|
||||
// where "ItemAct".act = 'VOTE' and "Item"."parentId" is null
|
||||
// and "Item"."userId" <> s.oid and "Item"."userId" <> s.id
|
||||
// and "Item".created_at >= u.created_at and "Item".created_at >= ou.created_at
|
||||
// group by s.id, s.oid, s.shared
|
||||
// ) a
|
||||
// group by id
|
||||
// ) b`
|
||||
// return graph
|
||||
// }
|
||||
|
||||
async function getGraph (models) {
|
||||
const [{ graph }] = await models.$queryRaw`
|
||||
select json_object_agg(id, hops) as graph
|
||||
from (
|
||||
select id, json_agg(json_build_object('node', oid, 'trust', trust)) as hops
|
||||
from (
|
||||
select s.id, s.oid, confidence(s.shared, count(*), ${Z_CONFIDENCE}) as trust
|
||||
from (
|
||||
select a."userId" as id, b."userId" as oid, count(*) as shared
|
||||
from "ItemAct" b
|
||||
join users bu on bu.id = b."userId"
|
||||
join "ItemAct" a on b."itemId" = a."itemId"
|
||||
join users au on au.id = a."userId"
|
||||
join "Item" on "Item".id = b."itemId"
|
||||
where b.act = 'VOTE'
|
||||
and a.act = 'VOTE'
|
||||
and "Item"."parentId" is null
|
||||
and "Item"."userId" <> b."userId"
|
||||
and "Item"."userId" <> a."userId"
|
||||
and b."userId" <> a."userId"
|
||||
and "Item".created_at >= au.created_at and "Item".created_at >= bu.created_at
|
||||
group by b."userId", a."userId") s
|
||||
join users u on s.id = u.id
|
||||
join users ou on s.oid = ou.id
|
||||
join "ItemAct" on "ItemAct"."userId" = s.oid
|
||||
join "Item" on "Item".id = "ItemAct"."itemId"
|
||||
where "ItemAct".act = 'VOTE' and "Item"."parentId" is null
|
||||
and "Item"."userId" <> s.oid and "Item"."userId" <> s.id
|
||||
and "Item".created_at >= u.created_at and "Item".created_at >= ou.created_at
|
||||
group by s.id, s.oid, s.shared
|
||||
SELECT json_object_agg(id, hops) AS graph
|
||||
FROM (
|
||||
SELECT id, json_agg(json_build_object('node', oid, 'trust', trust)) AS hops
|
||||
FROM (
|
||||
WITH user_votes AS (
|
||||
SELECT "ItemAct"."userId" AS user_id, users.name AS name, "ItemAct"."itemId" AS item_id, "ItemAct".created_at AS act_at,
|
||||
users.created_at AS user_at, "Item".created_at AS item_at, count(*) OVER (partition by "ItemAct"."userId") AS user_vote_count
|
||||
FROM "ItemAct"
|
||||
JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act = 'VOTE' AND "Item"."parentId" IS NULL
|
||||
JOIN users ON "ItemAct"."userId" = users.id
|
||||
),
|
||||
user_pair AS (
|
||||
SELECT a.user_id AS a_id, a.name AS a_name, b.user_id AS b_id, b.name AS b_name,
|
||||
count(*) FILTER(WHERE a.act_at > b.act_at) AS before,
|
||||
count(*) FILTER(WHERE b.act_at > a.act_at) AS after,
|
||||
CASE WHEN b.user_at > a.user_at THEN b.user_vote_count ELSE a.user_vote_count END AS total
|
||||
FROM user_votes a
|
||||
JOIN user_votes b ON a.item_id = b.item_id
|
||||
GROUP BY a.user_id, a.name, a.user_at, a.user_vote_count, b.user_id, b.name, b.user_at, b.user_vote_count
|
||||
)
|
||||
SELECT a_id AS id, a_name, b_id AS oid, b_name, confidence(before, total - after, ${Z_CONFIDENCE}) AS trust, before, after, total
|
||||
FROM user_pair
|
||||
WHERE before >= ${MIN_SUCCESS}
|
||||
) a
|
||||
group by id
|
||||
GROUP BY a.id
|
||||
) b`
|
||||
return graph
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue