Merge branch 'master' into 103-add-other-currencies

This commit is contained in:
ekzyis 2022-10-04 01:01:43 +02:00 committed by GitHub
commit 2dd4b1ce98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 1451 additions and 291 deletions

View File

@ -4,20 +4,22 @@ import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import domino from 'domino' import domino from 'domino'
import { BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../../lib/constants' import {
import { mdHas } from '../../lib/md' 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 let orderBy
switch (sort) { switch (sort) {
case 'top': case 'top':
orderBy = 'ORDER BY "Item"."weightedVotes" DESC, "Item".id DESC' orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".id DESC`
break break
case 'recent': case 'recent':
orderBy = 'ORDER BY "Item".created_at DESC, "Item".id DESC' orderBy = 'ORDER BY "Item".created_at DESC, "Item".id DESC'
break break
default: 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 break
} }
@ -26,18 +28,18 @@ async function comments (models, id, sort) {
${SELECT}, ARRAY[row_number() OVER (${orderBy}, "Item".path)] AS sort_path ${SELECT}, ARRAY[row_number() OVER (${orderBy}, "Item".path)] AS sort_path
FROM "Item" FROM "Item"
WHERE "parentId" = $1 WHERE "parentId" = $1
${await filterClause(me, models)}
UNION ALL UNION ALL
${SELECT}, p.sort_path || row_number() OVER (${orderBy}, "Item".path) ${SELECT}, p.sort_path || row_number() OVER (${orderBy}, "Item".path)
FROM base p 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)) SELECT * FROM base ORDER BY sort_path`, Number(id))
return nestComments(flat, id)[0] return nestComments(flat, id)[0]
} }
const COMMENTS_ORDER_BY_SATS = export async function getItem (parent, { id }, { me, models }) {
'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 }) {
const [item] = await models.$queryRaw(` const [item] = await models.$queryRaw(`
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
@ -67,6 +69,51 @@ function topClause (within) {
return interval 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 { export default {
Query: { Query: {
itemRepetition: async (parent, { parentId }, { me, models }) => { itemRepetition: async (parent, { parentId }, { me, models }) => {
@ -106,6 +153,7 @@ export default {
WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2 WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2
AND "pinId" IS NULL AND "pinId" IS NULL
${activeOrMine()} ${activeOrMine()}
${await filterClause(me, models)}
ORDER BY created_at DESC ORDER BY created_at DESC
OFFSET $3 OFFSET $3
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset) LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
@ -117,6 +165,7 @@ export default {
WHERE "parentId" IS NULL AND created_at <= $1 WHERE "parentId" IS NULL AND created_at <= $1
${subClause(3)} ${subClause(3)}
${activeOrMine()} ${activeOrMine()}
${await filterClause(me, models)}
ORDER BY created_at DESC ORDER BY created_at DESC
OFFSET $2 OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL') LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
@ -128,7 +177,8 @@ export default {
WHERE "parentId" IS NULL AND "Item".created_at <= $1 WHERE "parentId" IS NULL AND "Item".created_at <= $1
AND "pinId" IS NULL AND "pinId" IS NULL
${topClause(within)} ${topClause(within)}
${TOP_ORDER_BY_SATS} ${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
OFFSET $2 OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break break
@ -151,7 +201,7 @@ export default {
WHERE "parentId" IS NULL AND created_at <= $1 WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL AND "pinId" IS NULL
${subClause(3)} ${subClause(3)}
AND status = 'ACTIVE' AND status = 'ACTIVE' AND "maxBid" > 0
ORDER BY "maxBid" DESC, created_at ASC) ORDER BY "maxBid" DESC, created_at ASC)
UNION ALL UNION ALL
(${SELECT} (${SELECT}
@ -159,7 +209,7 @@ export default {
WHERE "parentId" IS NULL AND created_at <= $1 WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL AND "pinId" IS NULL
${subClause(3)} ${subClause(3)}
AND status = 'NOSATS' AND ((status = 'ACTIVE' AND "maxBid" = 0) OR status = 'NOSATS')
ORDER BY created_at DESC) ORDER BY created_at DESC)
) a ) a
OFFSET $2 OFFSET $2
@ -177,9 +227,10 @@ export default {
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1 AND "Item".created_at > $3 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)} ${subClause(4)}
${newTimedOrderByWeightedSats(1)} ${await filterClause(me, models)}
${await newTimedOrderByWeightedSats(me, models, 1)}
OFFSET $2 OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date().setDate(new Date().getDate() - 5)), sub || 'NULL') LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date().setDate(new Date().getDate() - 5)), sub || 'NULL')
} }
@ -189,9 +240,10 @@ export default {
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1 WHERE "parentId" IS NULL AND "Item".created_at <= $1
AND "pinId" IS NULL AND "pinId" IS NULL AND NOT bio
${subClause(3)} ${subClause(3)}
${newTimedOrderByWeightedSats(1)} ${await filterClause(me, models)}
${await newTimedOrderByWeightedSats(me, models, 1)}
OFFSET $2 OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL') LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
} }
@ -219,11 +271,66 @@ export default {
pins pins
} }
}, },
allItems: async (parent, { cursor }, { models }) => { allItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
const items = await models.$queryRaw(` const items = await models.$queryRaw(`
${SELECT} ${SELECT}
FROM "Item" 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 ORDER BY created_at DESC
OFFSET $1 OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset) LIMIT ${LIMIT}`, decodedCursor.offset)
@ -242,6 +349,7 @@ export default {
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
WHERE "parentId" IS NOT NULL AND created_at <= $1 WHERE "parentId" IS NOT NULL AND created_at <= $1
${await filterClause(me, models)}
ORDER BY created_at DESC ORDER BY created_at DESC
OFFSET $2 OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
@ -261,6 +369,7 @@ export default {
FROM "Item" FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NOT NULL WHERE "userId" = $1 AND "parentId" IS NOT NULL
AND created_at <= $2 AND created_at <= $2
${await filterClause(me, models)}
ORDER BY created_at DESC ORDER BY created_at DESC
OFFSET $3 OFFSET $3
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset) LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
@ -272,7 +381,8 @@ export default {
WHERE "parentId" IS NOT NULL WHERE "parentId" IS NOT NULL
AND "Item".created_at <= $1 AND "Item".created_at <= $1
${topClause(within)} ${topClause(within)}
${TOP_ORDER_BY_SATS} ${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
OFFSET $2 OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break break
@ -322,8 +432,8 @@ export default {
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 3`, similar) LIMIT 3`, similar)
}, },
comments: async (parent, { id, sort }, { models }) => { comments: async (parent, { id, sort }, { me, models }) => {
return comments(models, id, sort) return comments(me, models, id, sort)
}, },
search: async (parent, { q: query, sub, cursor }, { me, models, search }) => { search: async (parent, { q: query, sub, cursor }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
@ -346,11 +456,19 @@ export default {
bool: { bool: {
should: [ should: [
{ match: { status: 'ACTIVE' } }, { match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } } { match: { userId: me.id } }
] ]
} }
} }
: { match: { status: 'ACTIVE' } }, : {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
},
{ {
bool: { bool: {
should: [ should: [
@ -419,8 +537,9 @@ export default {
} }
// return highlights // return highlights
const items = sitems.body.hits.hits.map(e => { const items = sitems.body.hits.hits.map(async e => {
const item = e._source // 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.searchTitle = (e.highlight.title && e.highlight.title[0]) || item.title
item.searchText = (e.highlight.text && e.highlight.text[0]) || item.text item.searchText = (e.highlight.text && e.highlight.text[0]) || item.text
@ -433,17 +552,24 @@ export default {
items 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 // count items that have a bid gte to the current bid or
// gte current bid and older // gte current bid and older
const where = { const where = {
where: { where: {
subName: sub, subName: sub,
status: 'ACTIVE', status: { not: 'STOPPED' }
maxBid: {
gte: bid
} }
} }
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) { if (id) {
@ -491,8 +617,6 @@ export default {
} }
} }
const hasImgLink = !!(text && mdHas(text, ['link', 'image']))
if (id) { if (id) {
const optionCount = await models.pollOption.count({ const optionCount = await models.pollOption.count({
where: { where: {
@ -505,8 +629,8 @@ export default {
} }
const [item] = await serialize(models, const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6, $7) AS "Item"`, 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), hasImgLink)) Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id)))
return item return item
} else { } else {
@ -515,8 +639,8 @@ export default {
} }
const [item] = await serialize(models, const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`, 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), hasImgLink)) title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id)))
await createMentions(item, models) await createMentions(item, models)
@ -537,62 +661,36 @@ export default {
throw new UserInputError('not a valid sub', { argumentName: 'sub' }) throw new UserInputError('not a valid sub', { argumentName: 'sub' })
} }
if (fullSub.baseCost > maxBid) { if (maxBid < 0) {
throw new UserInputError(`bid must be at least ${fullSub.baseCost}`, { argumentName: 'maxBid' }) throw new UserInputError('bid must be at least 0', { argumentName: 'maxBid' })
} }
if (!location && !remote) { if (!location && !remote) {
throw new UserInputError('must specify location or remote', { argumentName: 'location' }) throw new UserInputError('must specify location or remote', { argumentName: 'location' })
} }
const checkSats = async () => { location = location.toLowerCase() === 'remote' ? undefined : location
// 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
}
let item
if (id) { 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) } }) const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) { if (Number(old.userId) !== Number(me?.id)) {
throw new AuthenticationError('item does not belong to you') throw new AuthenticationError('item does not belong to you')
} }
([item] = await serialize(models,
return await models.item.update({ models.$queryRaw(
where: { id: Number(id) }, `${SELECT} FROM update_job($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) AS "Item"`,
data 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 createMentions(item, models)
await checkSats()
return await models.item.create({ return item
data
})
}, },
createComment: async (parent, { text, parentId }, { me, models }) => { createComment: async (parent, { text, parentId }, { me, models }) => {
return await createItem(parent, { text, parentId }, { me, models }) return await createItem(parent, { text, parentId }, { me, models })
@ -636,9 +734,31 @@ export default {
vote, vote,
sats 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: { Item: {
isJob: async (item, args, { models }) => {
return item.subName === 'jobs'
},
sub: async (item, args, { models }) => { sub: async (item, args, { models }) => {
if (!item.subName) { if (!item.subName) {
return null return null
@ -710,11 +830,11 @@ export default {
} }
return await models.user.findUnique({ where: { id: item.fwdUserId } }) return await models.user.findUnique({ where: { id: item.fwdUserId } })
}, },
comments: async (item, args, { models }) => { comments: async (item, args, { me, models }) => {
if (item.comments) { if (item.comments) {
return item.comments return item.comments
} }
return comments(models, item.id, 'hot') return comments(me, models, item.id, 'hot')
}, },
upvotes: async (item, args, { models }) => { upvotes: async (item, args, { models }) => {
const { sum: { sats } } = await models.itemAct.aggregate({ const { sum: { sats } } = await models.itemAct.aggregate({
@ -768,6 +888,25 @@ export default {
return sats || 0 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 }) => { mine: async (item, args, { me, models }) => {
return me?.id === item.userId 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, const [item] = await serialize(models,
models.$queryRaw( models.$queryRaw(
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7) AS "Item"`, `${SELECT} FROM update_item($1, $2, $3, $4, $5, $6) AS "Item"`,
Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id), hasImgLink)) Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id)))
await createMentions(item, models) 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, const [item] = await serialize(models,
models.$queryRaw( 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), title, url, text, Number(boost || 0), Number(parentId), Number(me.id),
Number(fwdUser?.id), hasImgLink)) Number(fwdUser?.id)))
await createMentions(item, models) 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, `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".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
"Item".company, "Item".location, "Item".remote, "Item".company, "Item".location, "Item".remote,
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item"."paidImgLink", "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost",
"Item".sats, "Item".ncomments, "Item"."commentSats", "Item"."lastCommentAt", ltree2text("Item"."path") AS "path"` "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 ` 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` ("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`
}

View File

@ -1,6 +1,6 @@
import { AuthenticationError } from 'apollo-server-micro' import { AuthenticationError } from 'apollo-server-micro'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { getItem } from './item' import { getItem, filterClause } from './item'
import { getInvoice } from './wallet' import { getInvoice } from './wallet'
export default { export default {
@ -76,7 +76,8 @@ export default {
FROM "Item" FROM "Item"
JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
WHERE p."userId" = $1 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 { } else {
queries.push( queries.push(
@ -86,6 +87,7 @@ export default {
JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
WHERE p."userId" = $1 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)}
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)` LIMIT ${LIMIT}+$3)`
) )
@ -96,7 +98,6 @@ export default {
FROM "Item" FROM "Item"
WHERE "Item"."userId" = $1 WHERE "Item"."userId" = $1
AND "maxBid" IS NOT NULL AND "maxBid" IS NOT NULL
AND status <> 'STOPPED'
AND "statusUpdatedAt" <= $2 AND "statusUpdatedAt" <= $2
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)` LIMIT ${LIMIT}+$3)`
@ -129,6 +130,7 @@ export default {
AND "Mention".created_at <= $2 AND "Mention".created_at <= $2
AND "Item"."userId" <> $1 AND "Item"."userId" <> $1
AND (p."userId" IS NULL OR p."userId" <> $1) AND (p."userId" IS NULL OR p."userId" <> $1)
${await filterClause(me, models)}
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)` LIMIT ${LIMIT}+$3)`
) )
@ -162,18 +164,20 @@ export default {
if (meFull.noteEarning) { if (meFull.noteEarning) {
queries.push( 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 'Earn' AS type
FROM "Earn" FROM "Earn"
WHERE "userId" = $1 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 // we do all this crazy subquery stuff to make 'reward' islands
const notifications = await models.$queryRaw( 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 FROM
(SELECT *, (SELECT *,
CASE CASE
@ -214,6 +218,26 @@ export default {
JobChanged: { JobChanged: {
item: async (n, args, { models }) => getItem(n, { id: n.id }, { models }) 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: {
mention: async (n, args, { models }) => true, mention: async (n, args, { models }) => true,
item: async (n, args, { models }) => getItem(n, { id: n.id }, { models }) item: async (n, args, { models }) => getItem(n, { id: n.id }, { models })

View File

@ -1,7 +1,6 @@
import { AuthenticationError, UserInputError } from 'apollo-server-errors' import { AuthenticationError, UserInputError } from 'apollo-server-errors'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { mdHas } from '../../lib/md' import { createMentions, getItem, SELECT, updateItem, filterClause } from './item'
import { createMentions, getItem, SELECT, updateItem } from './item'
import serialize from './serial' import serialize from './serial'
export function topClause (within) { export function topClause (within) {
@ -202,11 +201,9 @@ export default {
if (user.bioId) { if (user.bioId) {
await updateItem(parent, { id: user.bioId, data: { text: bio, title: `@${user.name}'s bio` } }, { me, models }) await updateItem(parent, { id: user.bioId, data: { text: bio, title: `@${user.name}'s bio` } }, { me, models })
} else { } else {
const hasImgLink = !!(bio && mdHas(bio, ['link', 'image']))
const [item] = await serialize(models, const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM create_bio($1, $2, $3, $4) AS "Item"`, models.$queryRaw(`${SELECT} FROM create_bio($1, $2, $3) AS "Item"`,
`@${user.name}'s bio`, bio, Number(me.id), hasImgLink)) `@${user.name}'s bio`, bio, Number(me.id)))
await createMentions(item, models) await createMentions(item, models)
} }
@ -245,7 +242,10 @@ export default {
} }
try { 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) { } catch (error) {
if (error.code === 'P2002') { if (error.code === 'P2002') {
throw new UserInputError('email taken') 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'} JOIN "Item" p ON ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
WHERE p."userId" = $1 WHERE p."userId" = $1
AND "Item".created_at > $2 AND "Item"."userId" <> $1 AND "Item".created_at > $2 AND "Item"."userId" <> $1
${await filterClause(me, models)}
LIMIT 1`, me.id, lastChecked) LIMIT 1`, me.id, lastChecked)
if (newReplies.length > 0) { if (newReplies.length > 0) {
return true return true
@ -336,9 +337,6 @@ export default {
const job = await models.item.findFirst({ const job = await models.item.findFirst({
where: { where: {
status: {
not: 'STOPPED'
},
maxBid: { maxBid: {
not: null not: null
}, },

View File

@ -103,11 +103,12 @@ export default {
AND "ItemAct".created_at <= $2 AND "ItemAct".created_at <= $2
GROUP BY "Item".id)`) GROUP BY "Item".id)`)
queries.push( queries.push(
`(SELECT ('earn' || "Earn".id) as id, "Earn".id as "factId", NULL as bolt11, `(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11,
created_at as "createdAt", msats, created_at as "createdAt", sum(msats),
0 as "msatsFee", NULL as status, 'earn' as type 0 as "msatsFee", NULL as status, 'earn' as type
FROM "Earn" 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')) { if (include.has('spent')) {

View File

@ -12,6 +12,9 @@ export default gql`
search(q: String, sub: String, cursor: String): Items search(q: String, sub: String, cursor: String): Items
auctionPosition(sub: String, id: ID, bid: Int!): Int! auctionPosition(sub: String, id: ID, bid: Int!): Int!
itemRepetition(parentId: ID): Int! itemRepetition(parentId: ID): Int!
outlawedItems(cursor: String): Items
borderlandItems(cursor: String): Items
freebieItems(cursor: String): Items
} }
type ItemActResult { type ItemActResult {
@ -27,6 +30,7 @@ export default gql`
upsertPoll(id: ID, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item! upsertPoll(id: ID, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item!
createComment(text: String!, parentId: ID!): Item! createComment(text: String!, parentId: ID!): Item!
updateComment(id: ID!, text: String!): Item! updateComment(id: ID!, text: String!): Item!
dontLikeThis(id: ID!): Boolean!
act(id: ID!, sats: Int): ItemActResult! act(id: ID!, sats: Int): ItemActResult!
pollVote(id: ID!): ID! pollVote(id: ID!): ID!
} }
@ -78,6 +82,9 @@ export default gql`
lastCommentAt: String lastCommentAt: String
upvotes: Int! upvotes: Int!
meSats: Int! meSats: Int!
meDontLike: Boolean!
outlawed: Boolean!
freebie: Boolean!
paidImgLink: Boolean paidImgLink: Boolean
ncomments: Int! ncomments: Int!
comments: [Item!]! comments: [Item!]!
@ -85,6 +92,7 @@ export default gql`
position: Int position: Int
prior: Int prior: Int
maxBid: Int maxBid: Int
isJob: Boolean!
pollCost: Int pollCost: Int
poll: Poll poll: Poll
company: String company: String

View File

@ -32,9 +32,16 @@ export default gql`
sortTime: String! sortTime: String!
} }
type EarnSources {
posts: Int!
comments: Int!
tips: Int!
}
type Earn { type Earn {
earnedSats: Int! earnedSats: Int!
sortTime: String! sortTime: String!
sources: EarnSources
} }
type InvoicePaid { type InvoicePaid {

View File

@ -31,7 +31,8 @@ export default gql`
setName(name: String!): Boolean setName(name: String!): Boolean
setSettings(tipDefault: Int!, fiatCurrency: String!, noteItemSats: Boolean!, noteEarning: Boolean!, setSettings(tipDefault: Int!, fiatCurrency: String!, noteItemSats: Boolean!, noteEarning: Boolean!,
noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: 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! setPhoto(photoId: ID!): Int!
upsertBio(bio: String!): User! upsertBio(bio: String!): User!
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
@ -73,6 +74,8 @@ export default gql`
noteInvites: Boolean! noteInvites: Boolean!
noteJobIndicator: Boolean! noteJobIndicator: Boolean!
hideInvoiceDesc: Boolean! hideInvoiceDesc: Boolean!
wildWestMode: Boolean!
greeterMode: Boolean!
lastCheckedJobs: String lastCheckedJobs: String
authMethods: AuthMethods! authMethods: AuthMethods!
} }

View File

@ -73,7 +73,7 @@ export default function AdvPostForm ({ edit }) {
label={<>forward sats to</>} label={<>forward sats to</>}
name='forward' name='forward'
hint={<span className='text-muted'>100% of sats will be sent to this user</span>} 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 showValid
/> />
</> </>

View File

@ -3,7 +3,6 @@ import * as Yup from 'yup'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css' import styles from './reply.module.css'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { useState } from 'react'
import { EditFeeButton } from './fee-button' import { EditFeeButton } from './fee-button'
export const CommentSchema = Yup.object({ export const CommentSchema = Yup.object({
@ -11,14 +10,11 @@ export const CommentSchema = Yup.object({
}) })
export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) { export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) {
const [hasImgLink, setHasImgLink] = useState()
const [updateComment] = useMutation( const [updateComment] = useMutation(
gql` gql`
mutation updateComment($id: ID! $text: String!) { mutation updateComment($id: ID! $text: String!) {
updateComment(id: $id, text: $text) { updateComment(id: $id, text: $text) {
text text
paidImgLink
} }
}`, { }`, {
update (cache, { data: { updateComment } }) { update (cache, { data: { updateComment } }) {
@ -27,9 +23,6 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
fields: { fields: {
text () { text () {
return updateComment.text return updateComment.text
},
paidImgLink () {
return updateComment.paidImgLink
} }
} }
}) })
@ -59,11 +52,10 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
as={TextareaAutosize} as={TextareaAutosize}
minRows={6} minRows={6}
autoFocus autoFocus
setHasImgLink={setHasImgLink}
required required
/> />
<EditFeeButton <EditFeeButton
paidSats={comment.meSats} hadImgLink={comment.paidImgLink} hasImgLink={hasImgLink} paidSats={comment.meSats}
parentId={comment.parentId} text='save' ChildButton={SubmitButton} variant='secondary' parentId={comment.parentId} text='save' ChildButton={SubmitButton} variant='secondary'
/> />
</Form> </Form>

View File

@ -13,6 +13,10 @@ import CommentEdit from './comment-edit'
import Countdown from './countdown' import Countdown from './countdown'
import { COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants' import { COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants'
import { ignoreClick } from '../lib/clicks' 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 }) { function Parent ({ item, rootText }) {
const ParentFrag = () => ( const ParentFrag = () => (
@ -78,6 +82,7 @@ export default function Comment ({
const [edit, setEdit] = useState() const [edit, setEdit] = useState()
const [collapse, setCollapse] = useState(false) const [collapse, setCollapse] = useState(false)
const ref = useRef(null) const ref = useRef(null)
const me = useMe()
const router = useRouter() const router = useRouter()
const mine = item.mine const mine = item.mine
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 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 : ''}`} ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse ? styles.collapsed : ''}`}
> >
<div className={`${itemStyles.item} ${styles.item}`}> <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={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
<div className={`${itemStyles.other} ${styles.other}`}> <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> <a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link> </Link>
{includeParent && <Parent item={item} rootText={rootText} />} {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 && {canEdit &&
<> <>
<span> \ </span> <span> \ </span>

View File

@ -8,6 +8,14 @@
margin-top: 9px; margin-top: 9px;
} }
.dontLike {
fill: #a5a5a5;
margin-right: .2rem;
padding: 2px;
margin-left: 1px;
margin-top: 9px;
}
.text { .text {
margin-top: .1rem; margin-top: .1rem;
padding-right: 15px; padding-right: 15px;

View File

@ -6,7 +6,6 @@ import TextareaAutosize from 'react-textarea-autosize'
import Countdown from './countdown' import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
import { MAX_TITLE_LENGTH } from '../lib/constants' import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useState } from 'react'
import FeeButton, { EditFeeButton } from './fee-button' import FeeButton, { EditFeeButton } from './fee-button'
export function DiscussionForm ({ export function DiscussionForm ({
@ -16,7 +15,6 @@ export function DiscussionForm ({
}) { }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const [hasImgLink, setHasImgLink] = useState()
// const me = useMe() // const me = useMe()
const [upsertDiscussion] = useMutation( const [upsertDiscussion] = useMutation(
gql` gql`
@ -77,17 +75,16 @@ export function DiscussionForm ({
hint={editThreshold hint={editThreshold
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div> ? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
: null} : null}
setHasImgLink={setHasImgLink}
/> />
{adv && <AdvPostForm edit={!!item} />} {adv && <AdvPostForm edit={!!item} />}
<div className='mt-3'> <div className='mt-3'>
{item {item
? <EditFeeButton ? <EditFeeButton
paidSats={item.meSats} hadImgLink={item.paidImgLink} hasImgLink={hasImgLink} paidSats={item.meSats}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary' parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/> />
: <FeeButton : <FeeButton
baseFee={1} hasImgLink={hasImgLink} parentId={null} text={buttonText} baseFee={1} parentId={null} text={buttonText}
ChildButton={SubmitButton} variant='secondary' ChildButton={SubmitButton} variant='secondary'
/>} />}
</div> </div>

View File

@ -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>
)
}

View File

@ -14,6 +14,7 @@ import { randInRange } from '../lib/rand'
import { formatSats } from '../lib/format' import { formatSats } from '../lib/format'
import NoteIcon from '../svgs/notification-4-fill.svg' import NoteIcon from '../svgs/notification-4-fill.svg'
import { useQuery, gql } from '@apollo/client' import { useQuery, gql } from '@apollo/client'
import LightningIcon from '../svgs/bolt.svg'
function WalletSummary ({ me }) { function WalletSummary ({ me }) {
if (!me) return null if (!me) return null
@ -125,7 +126,18 @@ export default function Header ({ sub }) {
setFired(true) setFired(true)
}, [router.asPath]) }, [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>
} }
} }

View File

@ -83,7 +83,7 @@ function ItemEmbed ({ item }) {
} }
function TopLevelItem ({ item, noReply, ...props }) { function TopLevelItem ({ item, noReply, ...props }) {
const ItemComponent = item.maxBid ? ItemJob : Item const ItemComponent = item.isJob ? ItemJob : Item
return ( return (
<ItemComponent item={item} toc showFwdUser {...props}> <ItemComponent item={item} toc showFwdUser {...props}>

View File

@ -18,7 +18,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
{rank} {rank}
</div>) </div>)
: <div />} : <div />}
<div className={`${styles.item} ${item.status === 'NOSATS' && !item.mine ? styles.itemDead : ''}`}> <div className={`${styles.item}`}>
<Link href={`/items/${item.id}`} passHref> <Link href={`/items/${item.id}`} passHref>
<a> <a>
<Image <Image
@ -38,11 +38,6 @@ export default function ItemJob ({ item, toc, rank, children }) {
</Link> </Link>
</div> </div>
<div className={`${styles.other}`}> <div className={`${styles.other}`}>
{item.status === 'NOSATS' &&
<>
<span>expired</span>
{item.company && <span> \ </span>}
</>}
{item.company && {item.company &&
<> <>
{item.company} {item.company}
@ -72,7 +67,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
edit edit
</a> </a>
</Link> </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>
</div> </div>

View File

@ -11,6 +11,9 @@ import Toc from './table-of-contents'
import PollIcon from '../svgs/bar-chart-horizontal-fill.svg' import PollIcon from '../svgs/bar-chart-horizontal-fill.svg'
import { Badge } from 'react-bootstrap' import { Badge } from 'react-bootstrap'
import { newComments } from '../lib/new-comments' 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 }) { export function SearchTitle ({ title }) {
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { 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)) useState(mine && (Date.now() < editThreshold))
const [wrap, setWrap] = useState(false) const [wrap, setWrap] = useState(false)
const titleRef = useRef() const titleRef = useRef()
const me = useMe()
const [hasNewComments, setHasNewComments] = useState(false) const [hasNewComments, setHasNewComments] = useState(false)
useEffect(() => { useEffect(() => {
@ -58,7 +62,9 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
</div>) </div>)
: <div />} : <div />}
<div className={styles.item}> <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.hunk}>
<div className={`${styles.main} flex-wrap ${wrap ? 'd-inline' : ''}`}> <div className={`${styles.main} flex-wrap ${wrap ? 'd-inline' : ''}`}>
<Link href={`/items/${item.id}`} passHref> <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> <Link href={`/items/${item.id}`} passHref>
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a> <a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link> </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 && {item.prior &&
<> <>
<span> \ </span> <span> \ </span>

View File

@ -23,6 +23,7 @@ a.title:visited {
.newComment { .newComment {
color: var(--theme-grey) !important; color: var(--theme-grey) !important;
background: var(--theme-clickToContextColor) !important; background: var(--theme-clickToContextColor) !important;
vertical-align: middle;
} }
.pin { .pin {
@ -30,6 +31,13 @@ a.title:visited {
margin-right: .2rem; margin-right: .2rem;
} }
.dontLike {
fill: #a5a5a5;
margin-right: .2rem;
padding: 2px;
margin-left: 1px;
}
.case { .case {
fill: #a5a5a5; fill: #a5a5a5;
margin-right: .2rem; margin-right: .2rem;
@ -76,7 +84,7 @@ a.link:visited {
} }
.hunk { .hunk {
overflow: hidden; min-width: 0;
width: 100%; width: 100%;
line-height: 1.06rem; line-height: 1.06rem;
} }

47
components/items-mixed.js Normal file
View File

@ -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} />}
/>
</>
)
}

View File

@ -28,9 +28,14 @@ export default function Items ({ variables = {}, rank, items, pins, cursor }) {
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />} {pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />}
{item.parentId {item.parentId
? <><div /><div className='pb-3'><Comment item={item} noReply includeParent /></div></> ? <><div /><div className='pb-3'><Comment item={item} noReply includeParent /></div></>
: (item.maxBid : (item.isJob
? <ItemJob item={item} rank={rank && i + 1} /> ? <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> </React.Fragment>
))} ))}
</div> </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) const items = new Array(21).fill(null)
return ( return (

View File

@ -11,6 +11,8 @@ import { useRouter } from 'next/router'
import Link from 'next/link' import Link from 'next/link'
import { CURRENCY_SYMBOLS, usePrice } from './price' import { CURRENCY_SYMBOLS, usePrice } from './price'
import Avatar from './avatar' import Avatar from './avatar'
import BootstrapForm from 'react-bootstrap/Form'
import Alert from 'react-bootstrap/Alert'
Yup.addMethod(Yup.string, 'or', function (schemas, msg) { Yup.addMethod(Yup.string, 'or', function (schemas, msg) {
return this.test({ return this.test({
@ -37,7 +39,7 @@ function PriceHint ({ monthly }) {
const { fiatCurrency } = useMe(); const { fiatCurrency } = useMe();
const fiatSymbol = CURRENCY_SYMBOLS[fiatCurrency] const fiatSymbol = CURRENCY_SYMBOLS[fiatCurrency]
if (!price) { if (!price || !monthly) {
return null return null
} }
const fixed = (n, f) => Number.parseFloat(n).toFixed(f) const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
@ -50,13 +52,7 @@ function PriceHint ({ monthly }) {
export default function JobForm ({ item, sub }) { export default function JobForm ({ item, sub }) {
const storageKeyPrefix = item ? undefined : `${sub.name}-job` const storageKeyPrefix = item ? undefined : `${sub.name}-job`
const router = useRouter() const router = useRouter()
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || sub.baseCost))
const [logoId, setLogoId] = useState(item?.uploadId) 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` const [upsertJob] = useMutation(gql`
mutation upsertJob($id: ID, $title: String!, $company: String!, $location: String, mutation upsertJob($id: ID, $title: String!, $company: String!, $location: String,
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int) { $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() url: Yup.string()
.or([Yup.string().email(), Yup.string().url()], 'invalid url or email') .or([Yup.string().email(), Yup.string().url()], 'invalid url or email')
.required('required'), .required('required'),
maxBid: Yup.number('must be number') maxBid: Yup.number().typeError('must be a number')
.integer('must be whole').min(sub.baseCost, `must be at least ${sub.baseCost}`) .integer('must be whole').min(0, 'must be positive')
.required('required'), .required('required'),
location: Yup.string().test( location: Yup.string().test(
'no-remote', '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 ( return (
<> <>
<Form <Form
@ -107,7 +95,7 @@ export default function JobForm ({ item, sub }) {
remote: item?.remote || false, remote: item?.remote || false,
text: item?.text || '', text: item?.text || '',
url: item?.url || '', url: item?.url || '',
maxBid: item?.maxBid || sub.baseCost, maxBid: item?.maxBid || 0,
stop: false, stop: false,
start: false start: false
}} }}
@ -191,36 +179,66 @@ export default function JobForm ({ item, sub }) {
required required
clear 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 <Input
label={ label={
<div className='d-flex align-items-center'>bid <div className='d-flex align-items-center'>bid
<Info> <Info>
<ol className='font-weight-bold'> <ol className='font-weight-bold'>
<li>The higher your bid the higher your job will rank</li> <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, decrease, or remove your bid at anytime</li>
<li>You can increase or decrease your bid, and edit or stop your job at anytime</li> <li>You can 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>If you run out of sats, your job will stop being promoted until you fill your wallet again</li>
</ol> </ol>
</Info> </Info>
<small className='text-muted ml-2'>optional</small>
</div> </div>
} }
name='maxBid' name='maxBid'
onChange={async (formik, e) => { 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)) setMonthly(satsMin2Mo(e.target.value))
getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } }) getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } })
} else { } else {
setMonthly(satsMin2Mo(sub.baseCost)) setMonthly(satsMin2Mo(0))
} }
}} }}
append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>} append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>}
hint={<PriceHint monthly={monthly} />} hint={<PriceHint monthly={monthly} />}
storageKeyPrefix={storageKeyPrefix}
/> />
<><div className='font-weight-bold text-muted'>This bid puts your job in position: {position}</div></> <><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 = () => { StatusComp = () => {
return ( return (
<AccordianItem <AccordianItem
@ -261,12 +279,13 @@ function StatusControl ({ item }) {
} }
return ( 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' && {item.status === 'NOSATS' &&
<div className='text-danger font-weight-bold my-1'> <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>}
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>}
<StatusComp /> <StatusComp />
</div> </div>
</div>
) )
} }

View File

@ -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)' }} /> <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='ml-2'>
<div className='font-weight-bold text-boost'> <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> </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%' }}> <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. 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> </div>
@ -99,13 +105,15 @@ function Notification ({ n }) {
you were mentioned in you were mentioned in
</small>} </small>}
{n.__typename === 'JobChanged' && {n.__typename === 'JobChanged' &&
<small className={`font-weight-bold text-${n.item.status === 'NOSATS' ? 'danger' : 'success'} ml-1`}> <small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
{n.item.status === 'NOSATS' {n.item.status === 'ACTIVE'
? 'your job ran out of sats' ? 'your job is active again'
: 'your job is active again'} : (n.item.status === 'NOSATS'
? 'your job promotion ran out of sats'
: 'your job has been stopped')}
</small>} </small>}
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}> <div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}>
{n.item.maxBid {n.item.isJob
? <ItemJob item={n.item} /> ? <ItemJob item={n.item} />
: n.item.title : n.item.title
? <Item item={n.item} /> ? <Item item={n.item} />

View File

@ -6,13 +6,11 @@ import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
import { MAX_TITLE_LENGTH, MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES } from '../lib/constants' import { MAX_TITLE_LENGTH, MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES } from '../lib/constants'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { useState } from 'react'
import FeeButton, { EditFeeButton } from './fee-button' import FeeButton, { EditFeeButton } from './fee-button'
export function PollForm ({ item, editThreshold }) { export function PollForm ({ item, editThreshold }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const [hasImgLink, setHasImgLink] = useState()
const [upsertPoll] = useMutation( const [upsertPoll] = useMutation(
gql` gql`
@ -82,7 +80,6 @@ export function PollForm ({ item, editThreshold }) {
name='text' name='text'
as={TextareaAutosize} as={TextareaAutosize}
minRows={2} minRows={2}
setHasImgLink={setHasImgLink}
/> />
<VariableInput <VariableInput
label='choices' label='choices'
@ -97,11 +94,11 @@ export function PollForm ({ item, editThreshold }) {
<div className='mt-3'> <div className='mt-3'>
{item {item
? <EditFeeButton ? <EditFeeButton
paidSats={item.meSats} hadImgLink={item.paidImgLink} hasImgLink={hasImgLink} paidSats={item.meSats}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary' parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/> />
: <FeeButton : <FeeButton
baseFee={1} hasImgLink={hasImgLink} parentId={null} text='post' baseFee={1} parentId={null} text='post'
ChildButton={SubmitButton} variant='secondary' ChildButton={SubmitButton} variant='secondary'
/>} />}
</div> </div>

View File

@ -25,7 +25,6 @@ export function ReplyOnAnotherPage ({ parentId }) {
export default function Reply ({ item, onSuccess, replyOpen }) { export default function Reply ({ item, onSuccess, replyOpen }) {
const [reply, setReply] = useState(replyOpen) const [reply, setReply] = useState(replyOpen)
const me = useMe() const me = useMe()
const [hasImgLink, setHasImgLink] = useState()
const parentId = item.id const parentId = item.id
useEffect(() => { useEffect(() => {
@ -104,7 +103,6 @@ export default function Reply ({ item, onSuccess, replyOpen }) {
} }
resetForm({ text: '' }) resetForm({ text: '' })
setReply(replyOpen || false) setReply(replyOpen || false)
setHasImgLink(false)
}} }}
storageKeyPrefix={'reply-' + parentId} storageKeyPrefix={'reply-' + parentId}
> >
@ -114,13 +112,12 @@ export default function Reply ({ item, onSuccess, replyOpen }) {
minRows={6} minRows={6}
autoFocus={!replyOpen} autoFocus={!replyOpen}
required required
setHasImgLink={setHasImgLink}
hint={me?.freeComments ? <span className='text-success'>{me.freeComments} free comments left</span> : null} hint={me?.freeComments ? <span className='text-success'>{me.freeComments} free comments left</span> : null}
/> />
{reply && {reply &&
<div className='mt-1'> <div className='mt-1'>
<FeeButton <FeeButton
baseFee={1} hasImgLink={hasImgLink} parentId={parentId} text='reply' baseFee={1} parentId={parentId} text='reply'
ChildButton={SubmitButton} variant='secondary' alwaysShow ChildButton={SubmitButton} variant='secondary' alwaysShow
/> />
</div>} </div>}

View File

@ -1,6 +1,6 @@
import { Button, Container } from 'react-bootstrap' import { Button, Container } from 'react-bootstrap'
import styles from './search.module.css' 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 CloseIcon from '../svgs/close-line.svg'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Form, Input, SubmitButton } from './form' import { Form, Input, SubmitButton } from './form'

View File

@ -14,10 +14,12 @@ export const COMMENT_FIELDS = gql`
upvotes upvotes
boost boost
meSats meSats
meDontLike
outlawed
freebie
path path
commentSats commentSats
mine mine
paidImgLink
ncomments ncomments
root { root {
id id

View File

@ -21,10 +21,14 @@ export const ITEM_FIELDS = gql`
boost boost
path path
meSats meSats
meDontLike
outlawed
freebie
ncomments ncomments
commentSats commentSats
lastCommentAt lastCommentAt
maxBid maxBid
isJob
company company
location location
remote remote
@ -36,7 +40,6 @@ export const ITEM_FIELDS = gql`
status status
uploadId uploadId
mine mine
paidImgLink
root { root {
id id
title 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` export const POLL_FIELDS = gql`
fragment PollFields on Item { fragment PollFields on Item {
poll { poll {

View File

@ -31,6 +31,11 @@ export const NOTIFICATIONS = gql`
... on Earn { ... on Earn {
sortTime sortTime
earnedSats earnedSats
sources {
posts
comments
tips
}
} }
... on Reply { ... on Reply {
sortTime sortTime

View File

@ -26,6 +26,8 @@ export const ME = gql`
noteInvites noteInvites
noteJobIndicator noteJobIndicator
hideInvoiceDesc hideInvoiceDesc
wildWestMode
greeterMode
lastCheckedJobs lastCheckedJobs
} }
}` }`
@ -52,6 +54,8 @@ export const ME_SSR = gql`
noteInvites noteInvites
noteJobIndicator noteJobIndicator
hideInvoiceDesc hideInvoiceDesc
wildWestMode
greeterMode
lastCheckedJobs lastCheckedJobs
} }
}` }`
@ -68,6 +72,8 @@ export const SETTINGS_FIELDS = gql`
noteInvites noteInvites
noteJobIndicator noteJobIndicator
hideInvoiceDesc hideInvoiceDesc
wildWestMode
greeterMode
authMethods { authMethods {
lightning lightning
email email
@ -89,11 +95,13 @@ gql`
${SETTINGS_FIELDS} ${SETTINGS_FIELDS}
mutation setSettings($tipDefault: Int!, $fiatCurrency: String!, $noteItemSats: Boolean!, $noteEarning: Boolean!, mutation setSettings($tipDefault: Int!, $fiatCurrency: String!, $noteItemSats: Boolean!, $noteEarning: Boolean!,
$noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: 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, setSettings(tipDefault: $tipDefault, fiatCurrency: $fiatCurrency, noteItemSats: $noteItemSats,
noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites, noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc) { noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, wildWestMode: $wildWestMode,
greeterMode: $greeterMode) {
...SettingsFields ...SettingsFields
} }
} }

View File

@ -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: { search: {
keyArgs: ['q'], keyArgs: ['q'],
merge (existing, incoming) { merge (existing, incoming) {

View File

@ -14,3 +14,5 @@ export const MAX_TITLE_LENGTH = 80
export const MAX_POLL_CHOICE_LENGTH = 30 export const MAX_POLL_CHOICE_LENGTH = 30
export const ITEM_SPAM_INTERVAL = '10m' export const ITEM_SPAM_INTERVAL = '10m'
export const MAX_POLL_NUM_CHOICES = 10 export const MAX_POLL_NUM_CHOICES = 10
export const ITEM_FILTER_THRESHOLD = 1.2
export const DONT_LIKE_THIS_COST = 1

View File

@ -23,8 +23,6 @@ const BioSchema = Yup.object({
}) })
export function BioForm ({ handleSuccess, bio }) { export function BioForm ({ handleSuccess, bio }) {
const [hasImgLink, setHasImgLink] = useState()
const [upsertBio] = useMutation( const [upsertBio] = useMutation(
gql` gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
@ -70,16 +68,15 @@ export function BioForm ({ handleSuccess, bio }) {
name='bio' name='bio'
as={TextareaAutosize} as={TextareaAutosize}
minRows={6} minRows={6}
setHasImgLink={setHasImgLink}
/> />
<div className='mt-3'> <div className='mt-3'>
{bio?.text {bio?.text
? <EditFeeButton ? <EditFeeButton
paidSats={bio?.meSats} hadImgLink={bio?.paidImgLink} hasImgLink={hasImgLink} paidSats={bio?.meSats}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary' parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/> />
: <FeeButton : <FeeButton
baseFee={1} hasImgLink={hasImgLink} parentId={null} text='create' baseFee={1} parentId={null} text='create'
ChildButton={SubmitButton} variant='secondary' ChildButton={SubmitButton} variant='secondary'
/>} />}
</div> </div>

32
pages/borderland.js Normal file
View File

@ -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} />
}

32
pages/freebie.js Normal file
View File

@ -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} />
}

View File

@ -14,7 +14,7 @@ export default function PostEdit ({ data: { item } }) {
return ( return (
<LayoutCenter sub={item.sub?.name}> <LayoutCenter sub={item.sub?.name}>
{item.maxBid {item.isJob
? <JobForm item={item} sub={item.sub} /> ? <JobForm item={item} sub={item.sub} />
: (item.url : (item.url
? <LinkForm item={item} editThreshold={editThreshold} adv /> ? <LinkForm item={item} editThreshold={editThreshold} adv />

View File

@ -6,7 +6,7 @@ import { getGetServerSideProps } from '../../../api/ssrApollo'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
export const getServerSideProps = getGetServerSideProps(ITEM_FULL, null, 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 } }) { export default function AnItem ({ data: { item } }) {
const { data } = useQuery(ITEM_FULL, { const { data } = useQuery(ITEM_FULL, {

32
pages/outlawed.js Normal file
View File

@ -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} />
}

View File

@ -66,7 +66,9 @@ export default function Settings ({ data: { settings } }) {
noteDeposits: settings?.noteDeposits, noteDeposits: settings?.noteDeposits,
noteInvites: settings?.noteInvites, noteInvites: settings?.noteInvites,
noteJobIndicator: settings?.noteJobIndicator, noteJobIndicator: settings?.noteJobIndicator,
hideInvoiceDesc: settings?.hideInvoiceDesc hideInvoiceDesc: settings?.hideInvoiceDesc,
wildWestMode: settings?.wildWestMode,
greeterMode: settings?.greeterMode
}} }}
schema={SettingsSchema} schema={SettingsSchema}
onSubmit={async ({ tipDefault, fiatCurrency, ...values }) => { onSubmit={async ({ tipDefault, fiatCurrency, ...values }) => {
@ -126,7 +128,7 @@ export default function Settings ({ data: { settings } }) {
<div className='form-label'>privacy</div> <div className='form-label'>privacy</div>
<Checkbox <Checkbox
label={ label={
<>hide invoice descriptions <div className='d-flex align-items-center'>hide invoice descriptions
<Info> <Info>
<ul className='font-weight-bold'> <ul className='font-weight-bold'>
<li>Use this if you don't want funding sources to be linkable to your SN identity.</li> <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> </li>
</ul> </ul>
</Info> </Info>
</> </div>
} }
name='hideInvoiceDesc' 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'> <div className='d-flex'>
<SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton> <SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton>
</div> </div>

View File

@ -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");

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
$$;

View File

@ -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;
$$;

View File

@ -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;

View File

@ -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;
$$;

View File

@ -0,0 +1,4 @@
INSERT INTO "users" ("name") VALUES
('freebie'),
('borderland'),
('outlawed');

View File

@ -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;
$$;

View File

@ -32,8 +32,8 @@ model User {
bioId Int? bioId Int?
msats Int @default(0) msats Int @default(0)
stackedMsats Int @default(0) stackedMsats Int @default(0)
freeComments Int @default(0) freeComments Int @default(5)
freePosts Int @default(0) freePosts Int @default(2)
checkedNotesAt DateTime? checkedNotesAt DateTime?
tipDefault Int @default(10) tipDefault Int @default(10)
fiatCurrency String @default("USD") fiatCurrency String @default("USD")
@ -60,6 +60,10 @@ model User {
// privacy settings // privacy settings
hideInvoiceDesc Boolean @default(false) hideInvoiceDesc Boolean @default(false)
// content settings
wildWestMode Boolean @default(false)
greeterMode Boolean @default(false)
Earn Earn[] Earn Earn[]
Upload Upload[] @relation(name: "Uploads") Upload Upload[] @relation(name: "Uploads")
PollVote PollVote[] PollVote PollVote[]
@ -89,6 +93,13 @@ model Upload {
@@index([userId]) @@index([userId])
} }
enum EarnType {
POST
COMMENT
TIP_COMMENT
TIP_POST
}
model Earn { model Earn {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at") createdAt DateTime @default(now()) @map(name: "created_at")
@ -98,8 +109,13 @@ model Earn {
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId Int userId Int
type EarnType?
typeId Int?
rank Int?
@@index([createdAt]) @@index([createdAt])
@@index([userId]) @@index([userId])
@@index([createdAt, userId])
} }
model LnAuth { model LnAuth {
@ -171,8 +187,13 @@ model Item {
upload Upload? upload Upload?
paidImgLink Boolean @default(false) paidImgLink Boolean @default(false)
// is free post or bio
freebie Boolean @default(false)
bio Boolean @default(false)
// denormalized self stats // denormalized self stats
weightedVotes Float @default(0) weightedVotes Float @default(0)
weightedDownVotes Float @default(0)
sats Int @default(0) sats Int @default(0)
// denormalized comment stats // denormalized comment stats
@ -285,6 +306,7 @@ enum ItemActType {
TIP TIP
STREAM STREAM
POLL POLL
DONT_LIKE_THIS
} }
model ItemAct { model ItemAct {

View File

@ -217,7 +217,7 @@ a:hover {
background-color: var(--theme-inputBg); background-color: var(--theme-inputBg);
border: 1px solid var(--theme-borderColor); border: 1px solid var(--theme-borderColor);
max-width: 90vw; max-width: 90vw;
overflow: scroll; overflow: auto;
} }
.dropdown-item { .dropdown-item {

1
svgs/cloud-fill.svg Normal file
View File

@ -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

View File

@ -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

1
svgs/flag-2-fill.svg Normal file
View File

@ -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

1
svgs/flag-fill.svg Normal file
View File

@ -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

1
svgs/more-fill.svg Normal file
View File

@ -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

1
svgs/more-line.svg Normal file
View File

@ -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

View File

@ -2,8 +2,7 @@ const serialize = require('../api/resolvers/serial')
const ITEM_EACH_REWARD = 3.0 const ITEM_EACH_REWARD = 3.0
const UPVOTE_EACH_REWARD = 6.0 const UPVOTE_EACH_REWARD = 6.0
const TOP_ITEMS = 21 const TOP_PERCENTILE = 21
const EARLY_MULTIPLIER_MAX = 100.0
// TODO: use a weekly trust measure or make trust decay // TODO: use a weekly trust measure or make trust decay
function earn ({ models }) { function earn ({ models }) {
@ -11,7 +10,7 @@ function earn ({ models }) {
console.log('running', name) console.log('running', name)
// compute how much sn earned today // compute how much sn earned today
const [{ sum }] = await models.$queryRaw` let [{ sum }] = await models.$queryRaw`
SELECT sum("ItemAct".sats) SELECT sum("ItemAct".sats)
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id 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")) OR ("ItemAct".act IN ('VOTE','POLL') AND "Item"."userId" = "ItemAct"."userId"))
AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'` AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'`
// convert to msats
sum = sum * 1000
/* /*
How earnings work: How earnings work:
1/3: top 21 posts 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 21% comments over last 36 hours, scored on a relative basis
1/3: top upvoters of top posts/comments, scored on: 1/3: top upvoters of top posts/comments, scored on:
- their trust - their trust
- how much they tipped - how much they tipped
@ -30,19 +32,27 @@ function earn ({ models }) {
- how the post/comment scored - 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(` const earners = await models.$queryRaw(`
WITH item_ratios AS ( WITH item_ratios AS (
SELECT *, 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 ( FROM (
SELECT *, 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 FROM
"Item" "Item"
WHERE created_at >= now_utc() - interval '36 hours' WHERE created_at >= now_utc() - interval '36 hours'
AND "weightedVotes" > 0
) x ) x
WHERE x.r <= ${TOP_ITEMS} WHERE x.percentile <= ${TOP_PERCENTILE}
), ),
upvoters AS ( upvoters AS (
SELECT "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId", 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" GROUP BY "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId"
), ),
upvoter_ratios AS ( upvoter_ratios AS (
SELECT "userId", sum(early_multiplier*tipped_ratio*ratio*users.trust) as upvoting_score, SELECT "userId", sum(early_multiplier*tipped_ratio*ratio*users.trust) as upvoter_ratio,
"parentId" IS NULL as "isPost" "parentId" IS NULL as "isPost", CASE WHEN "parentId" IS NULL THEN 'TIP_POST' ELSE 'TIP_COMMENT' END as type
FROM ( FROM (
SELECT *, 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 tipped::float/(sum(tipped) OVER (partition by id)) tipped_ratio
FROM upvoters FROM upvoters
) u ) u
JOIN users on "userId" = users.id JOIN users on "userId" = users.id
GROUP BY "userId", "parentId" IS NULL GROUP BY "userId", "parentId" IS NULL
) )
SELECT "userId" as id, FLOOR(sum(proportion)*${sum}*1000) as earnings SELECT "userId", NULL as id, type, ROW_NUMBER() OVER (PARTITION BY "isPost" ORDER BY upvoter_ratio DESC) as rank,
FROM ( upvoter_ratio/(sum(upvoter_ratio) OVER (PARTITION BY "isPost"))/${UPVOTE_EACH_REWARD} as proportion
SELECT "userId",
upvoting_score/(sum(upvoting_score) OVER (PARTITION BY "isPost"))/${UPVOTE_EACH_REWARD} as proportion
FROM upvoter_ratios FROM upvoter_ratios
WHERE upvoter_ratio > 0
UNION ALL UNION ALL
SELECT "userId", ratio/${ITEM_EACH_REWARD} as proportion SELECT "userId", id, type, rank, ratio/${ITEM_EACH_REWARD} as proportion
FROM item_ratios FROM item_ratios`)
) a
GROUP BY "userId" // in order to group earnings for users we use the same createdAt time for
HAVING FLOOR(sum(proportion)*${sum}) >= 1`) // 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 // for each earner, serialize earnings
// we do this for each earner because we don't need to serialize // we do this for each earner because we don't need to serialize
// all earner updates together // all earner updates together
earners.forEach(async earner => { 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, 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})`)
} }
}) })

View File

@ -12,6 +12,7 @@ function trust ({ boss, models }) {
// only explore a path up to this depth from start // only explore a path up to this depth from start
const MAX_DEPTH = 6 const MAX_DEPTH = 6
const MAX_TRUST = 0.9 const MAX_TRUST = 0.9
const MIN_SUCCESS = 5
// https://en.wikipedia.org/wiki/Normal_distribution#Quantile_function // https://en.wikipedia.org/wiki/Normal_distribution#Quantile_function
const Z_CONFIDENCE = 2.326347874041 // 98% confidence const Z_CONFIDENCE = 2.326347874041 // 98% confidence
@ -162,39 +163,70 @@ function trustGivenGraph (graph, start) {
// return graph // 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) { async function getGraph (models) {
const [{ graph }] = await models.$queryRaw` const [{ graph }] = await models.$queryRaw`
select json_object_agg(id, hops) as graph SELECT json_object_agg(id, hops) AS graph
from ( FROM (
select id, json_agg(json_build_object('node', oid, 'trust', trust)) as hops SELECT id, json_agg(json_build_object('node', oid, 'trust', trust)) AS hops
from ( FROM (
select s.id, s.oid, confidence(s.shared, count(*), ${Z_CONFIDENCE}) as trust WITH user_votes AS (
from ( SELECT "ItemAct"."userId" AS user_id, users.name AS name, "ItemAct"."itemId" AS item_id, "ItemAct".created_at AS act_at,
select a."userId" as id, b."userId" as oid, count(*) as shared users.created_at AS user_at, "Item".created_at AS item_at, count(*) OVER (partition by "ItemAct"."userId") AS user_vote_count
from "ItemAct" b FROM "ItemAct"
join users bu on bu.id = b."userId" JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act = 'VOTE' AND "Item"."parentId" IS NULL
join "ItemAct" a on b."itemId" = a."itemId" JOIN users ON "ItemAct"."userId" = users.id
join users au on au.id = a."userId" ),
join "Item" on "Item".id = b."itemId" user_pair AS (
where b.act = 'VOTE' SELECT a.user_id AS a_id, a.name AS a_name, b.user_id AS b_id, b.name AS b_name,
and a.act = 'VOTE' count(*) FILTER(WHERE a.act_at > b.act_at) AS before,
and "Item"."parentId" is null count(*) FILTER(WHERE b.act_at > a.act_at) AS after,
and "Item"."userId" <> b."userId" CASE WHEN b.user_at > a.user_at THEN b.user_vote_count ELSE a.user_vote_count END AS total
and "Item"."userId" <> a."userId" FROM user_votes a
and b."userId" <> a."userId" JOIN user_votes b ON a.item_id = b.item_id
and "Item".created_at >= au.created_at and "Item".created_at >= bu.created_at 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
group by b."userId", a."userId") s )
join users u on s.id = u.id SELECT a_id AS id, a_name, b_id AS oid, b_name, confidence(before, total - after, ${Z_CONFIDENCE}) AS trust, before, after, total
join users ou on s.oid = ou.id FROM user_pair
join "ItemAct" on "ItemAct"."userId" = s.oid WHERE before >= ${MIN_SUCCESS}
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 ) a
group by id GROUP BY a.id
) b` ) b`
return graph return graph
} }