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 { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import domino from 'domino'
import { BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../../lib/constants'
import { mdHas } from '../../lib/md'
import {
BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES,
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST
} from '../../lib/constants'
async function comments (models, id, sort) {
async function comments (me, models, id, sort) {
let orderBy
switch (sort) {
case 'top':
orderBy = 'ORDER BY "Item"."weightedVotes" DESC, "Item".id DESC'
orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".id DESC`
break
case 'recent':
orderBy = 'ORDER BY "Item".created_at DESC, "Item".id DESC'
break
default:
orderBy = COMMENTS_ORDER_BY_SATS
orderBy = `ORDER BY ${await orderByNumerator(me, models)}/POWER(EXTRACT(EPOCH FROM ((NOW() AT TIME ZONE 'UTC') - "Item".created_at))/3600+2, 1.3) DESC NULLS LAST, "Item".id DESC`
break
}
@ -26,18 +28,18 @@ async function comments (models, id, sort) {
${SELECT}, ARRAY[row_number() OVER (${orderBy}, "Item".path)] AS sort_path
FROM "Item"
WHERE "parentId" = $1
${await filterClause(me, models)}
UNION ALL
${SELECT}, p.sort_path || row_number() OVER (${orderBy}, "Item".path)
FROM base p
JOIN "Item" ON "Item"."parentId" = p.id)
JOIN "Item" ON "Item"."parentId" = p.id
WHERE true
${await filterClause(me, models)})
SELECT * FROM base ORDER BY sort_path`, Number(id))
return nestComments(flat, id)[0]
}
const COMMENTS_ORDER_BY_SATS =
'ORDER BY POWER("Item"."weightedVotes", 1.2)/POWER(EXTRACT(EPOCH FROM ((NOW() AT TIME ZONE \'UTC\') - "Item".created_at))/3600+2, 1.3) DESC NULLS LAST, "Item".id DESC'
export async function getItem (parent, { id }, { models }) {
export async function getItem (parent, { id }, { me, models }) {
const [item] = await models.$queryRaw(`
${SELECT}
FROM "Item"
@ -67,6 +69,51 @@ function topClause (within) {
return interval
}
export async function orderByNumerator (me, models) {
if (me) {
const user = await models.user.findUnique({ where: { id: me.id } })
if (user.wildWestMode) {
return 'GREATEST("Item"."weightedVotes", POWER("Item"."weightedVotes", 1.2))'
}
}
return `(CASE WHEN "Item"."weightedVotes" > "Item"."weightedDownVotes"
THEN 1
ELSE -1 END
* GREATEST(ABS("Item"."weightedVotes" - "Item"."weightedDownVotes"), POWER(ABS("Item"."weightedVotes" - "Item"."weightedDownVotes"), 1.2)))`
}
export async function filterClause (me, models) {
// by default don't include freebies unless they have upvotes
let clause = ' AND (NOT "Item".freebie OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0'
if (me) {
const user = await models.user.findUnique({ where: { id: me.id } })
// wild west mode has everything
if (user.wildWestMode) {
return ''
}
// greeter mode includes freebies if feebies haven't been flagged
if (user.greeterMode) {
clause = 'AND (NOT "Item".freebie OR ("Item"."weightedVotes" - "Item"."weightedDownVotes" >= 0 AND "Item".freebie)'
}
// always include if it's mine
clause += ` OR "Item"."userId" = ${me.id})`
} else {
// close default freebie clause
clause += ')'
}
// if the item is above the threshold or is mine
clause += ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
if (me) {
clause += ` OR "Item"."userId" = ${me.id}`
}
clause += ')'
return clause
}
export default {
Query: {
itemRepetition: async (parent, { parentId }, { me, models }) => {
@ -106,6 +153,7 @@ export default {
WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2
AND "pinId" IS NULL
${activeOrMine()}
${await filterClause(me, models)}
ORDER BY created_at DESC
OFFSET $3
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
@ -117,6 +165,7 @@ export default {
WHERE "parentId" IS NULL AND created_at <= $1
${subClause(3)}
${activeOrMine()}
${await filterClause(me, models)}
ORDER BY created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
@ -128,7 +177,8 @@ export default {
WHERE "parentId" IS NULL AND "Item".created_at <= $1
AND "pinId" IS NULL
${topClause(within)}
${TOP_ORDER_BY_SATS}
${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break
@ -151,7 +201,7 @@ export default {
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
AND status = 'ACTIVE'
AND status = 'ACTIVE' AND "maxBid" > 0
ORDER BY "maxBid" DESC, created_at ASC)
UNION ALL
(${SELECT}
@ -159,7 +209,7 @@ export default {
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
AND status = 'NOSATS'
AND ((status = 'ACTIVE' AND "maxBid" = 0) OR status = 'NOSATS')
ORDER BY created_at DESC)
) a
OFFSET $2
@ -177,9 +227,10 @@ export default {
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1 AND "Item".created_at > $3
AND "pinId" IS NULL
AND "pinId" IS NULL AND NOT bio
${subClause(4)}
${newTimedOrderByWeightedSats(1)}
${await filterClause(me, models)}
${await newTimedOrderByWeightedSats(me, models, 1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date().setDate(new Date().getDate() - 5)), sub || 'NULL')
}
@ -189,9 +240,10 @@ export default {
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1
AND "pinId" IS NULL
AND "pinId" IS NULL AND NOT bio
${subClause(3)}
${newTimedOrderByWeightedSats(1)}
${await filterClause(me, models)}
${await newTimedOrderByWeightedSats(me, models, 1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
}
@ -219,11 +271,66 @@ export default {
pins
}
},
allItems: async (parent, { cursor }, { models }) => {
allItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const items = await models.$queryRaw(`
${SELECT}
FROM "Item"
${await filterClause(me, models)}
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
outlawedItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const notMine = () => {
return me ? ` AND "userId" <> ${me.id} ` : ''
}
const items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD}
${notMine()}
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
borderlandItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const notMine = () => {
return me ? ` AND "userId" <> ${me.id} ` : ''
}
const items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" < 0
AND "Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}
${notMine()}
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
freebieItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "Item".freebie
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset)
@ -242,6 +349,7 @@ export default {
${SELECT}
FROM "Item"
WHERE "parentId" IS NOT NULL AND created_at <= $1
${await filterClause(me, models)}
ORDER BY created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
@ -261,6 +369,7 @@ export default {
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NOT NULL
AND created_at <= $2
${await filterClause(me, models)}
ORDER BY created_at DESC
OFFSET $3
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
@ -272,7 +381,8 @@ export default {
WHERE "parentId" IS NOT NULL
AND "Item".created_at <= $1
${topClause(within)}
${TOP_ORDER_BY_SATS}
${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break
@ -322,8 +432,8 @@ export default {
ORDER BY created_at DESC
LIMIT 3`, similar)
},
comments: async (parent, { id, sort }, { models }) => {
return comments(models, id, sort)
comments: async (parent, { id, sort }, { me, models }) => {
return comments(me, models, id, sort)
},
search: async (parent, { q: query, sub, cursor }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor)
@ -346,11 +456,19 @@ export default {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } }
]
}
}
: { match: { status: 'ACTIVE' } },
: {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
},
{
bool: {
should: [
@ -419,8 +537,9 @@ export default {
}
// return highlights
const items = sitems.body.hits.hits.map(e => {
const item = e._source
const items = sitems.body.hits.hits.map(async e => {
// this is super inefficient but will suffice until we do something more generic
const item = await getItem(parent, { id: e._source.id }, { me, models })
item.searchTitle = (e.highlight.title && e.highlight.title[0]) || item.title
item.searchText = (e.highlight.text && e.highlight.text[0]) || item.text
@ -433,17 +552,24 @@ export default {
items
}
},
auctionPosition: async (parent, { id, sub, bid }, { models }) => {
auctionPosition: async (parent, { id, sub, bid }, { models, me }) => {
// count items that have a bid gte to the current bid or
// gte current bid and older
const where = {
where: {
subName: sub,
status: 'ACTIVE',
maxBid: {
gte: bid
status: { not: 'STOPPED' }
}
}
if (bid > 0) {
where.where.maxBid = { gte: bid }
} else {
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
where.where.OR = [
{ maxBid: { gt: 0 } },
{ createdAt: { gt: createdAt } }
]
}
if (id) {
@ -491,8 +617,6 @@ export default {
}
}
const hasImgLink = !!(text && mdHas(text, ['link', 'image']))
if (id) {
const optionCount = await models.pollOption.count({
where: {
@ -505,8 +629,8 @@ export default {
}
const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id), hasImgLink))
models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6) AS "Item"`,
Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id)))
return item
} else {
@ -515,8 +639,8 @@ export default {
}
const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id), hasImgLink))
models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6, $7, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id)))
await createMentions(item, models)
@ -537,62 +661,36 @@ export default {
throw new UserInputError('not a valid sub', { argumentName: 'sub' })
}
if (fullSub.baseCost > maxBid) {
throw new UserInputError(`bid must be at least ${fullSub.baseCost}`, { argumentName: 'maxBid' })
if (maxBid < 0) {
throw new UserInputError('bid must be at least 0', { argumentName: 'maxBid' })
}
if (!location && !remote) {
throw new UserInputError('must specify location or remote', { argumentName: 'location' })
}
const checkSats = async () => {
// check if the user has the funds to run for the first minute
const minuteMsats = maxBid * 1000
const user = await models.user.findUnique({ where: { id: me.id } })
if (user.msats < minuteMsats) {
throw new UserInputError('insufficient funds')
}
}
const data = {
title,
company,
location: location.toLowerCase() === 'remote' ? undefined : location,
remote,
text,
url,
maxBid,
subName: sub,
userId: me.id,
uploadId: logo
}
location = location.toLowerCase() === 'remote' ? undefined : location
let item
if (id) {
if (status) {
data.status = status
// if the job is changing to active, we need to check they have funds
if (status === 'ACTIVE') {
await checkSats()
}
}
const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) {
throw new AuthenticationError('item does not belong to you')
}
return await models.item.update({
where: { id: Number(id) },
data
})
([item] = await serialize(models,
models.$queryRaw(
`${SELECT} FROM update_job($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) AS "Item"`,
Number(id), title, url, text, Number(maxBid), company, location, remote, Number(logo), status)))
} else {
([item] = await serialize(models,
models.$queryRaw(
`${SELECT} FROM create_job($1, $2, $3, $4, $5, $6, $7, $8, $9) AS "Item"`,
title, url, text, Number(me.id), Number(maxBid), company, location, remote, Number(logo))))
}
// before creating job, check the sats
await checkSats()
return await models.item.create({
data
})
await createMentions(item, models)
return item
},
createComment: async (parent, { text, parentId }, { me, models }) => {
return await createItem(parent, { text, parentId }, { me, models })
@ -636,9 +734,31 @@ export default {
vote,
sats
}
},
dontLikeThis: async (parent, { id }, { me, models }) => {
// need to make sure we are logged in
if (!me) {
throw new AuthenticationError('you must be logged in')
}
// disallow self down votes
const [item] = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
if (item) {
throw new UserInputError('cannot downvote your self')
}
await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST})`)
return true
}
},
Item: {
isJob: async (item, args, { models }) => {
return item.subName === 'jobs'
},
sub: async (item, args, { models }) => {
if (!item.subName) {
return null
@ -710,11 +830,11 @@ export default {
}
return await models.user.findUnique({ where: { id: item.fwdUserId } })
},
comments: async (item, args, { models }) => {
comments: async (item, args, { me, models }) => {
if (item.comments) {
return item.comments
}
return comments(models, item.id, 'hot')
return comments(me, models, item.id, 'hot')
},
upvotes: async (item, args, { models }) => {
const { sum: { sats } } = await models.itemAct.aggregate({
@ -768,6 +888,25 @@ export default {
return sats || 0
},
meDontLike: async (item, args, { me, models }) => {
if (!me) return false
const dontLike = await models.itemAct.findFirst({
where: {
itemId: Number(item.id),
userId: me.id,
act: 'DONT_LIKE_THIS'
}
})
return !!dontLike
},
outlawed: async (item, args, { me, models }) => {
if (me && Number(item.userId) === Number(me.id)) {
return false
}
return item.weightedVotes - item.weightedDownVotes <= -ITEM_FILTER_THRESHOLD
},
mine: async (item, args, { me, models }) => {
return me?.id === item.userId
},
@ -860,12 +999,10 @@ export const updateItem = async (parent, { id, data: { title, url, text, boost,
}
}
const hasImgLink = !!(text && mdHas(text, ['link', 'image']))
const [item] = await serialize(models,
models.$queryRaw(
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id), hasImgLink))
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6) AS "Item"`,
Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id)))
await createMentions(item, models)
@ -893,13 +1030,11 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId }
}
}
const hasImgLink = !!(text && mdHas(text, ['link', 'image']))
const [item] = await serialize(models,
models.$queryRaw(
`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
title, url, text, Number(boost || 0), Number(parentId), Number(me.id),
Number(fwdUser?.id), hasImgLink))
Number(fwdUser?.id)))
await createMentions(item, models)
@ -937,13 +1072,16 @@ export const SELECT =
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
"Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
"Item".company, "Item".location, "Item".remote,
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item"."paidImgLink",
"Item".sats, "Item".ncomments, "Item"."commentSats", "Item"."lastCommentAt", ltree2text("Item"."path") AS "path"`
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost",
"Item".sats, "Item".ncomments, "Item"."commentSats", "Item"."lastCommentAt", "Item"."weightedVotes",
"Item"."weightedDownVotes", "Item".freebie, ltree2text("Item"."path") AS "path"`
function newTimedOrderByWeightedSats (num) {
async function newTimedOrderByWeightedSats (me, models, num) {
return `
ORDER BY (POWER("Item"."weightedVotes", 1.2)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 1.3) +
ORDER BY (${await orderByNumerator(me, models)}/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 1.3) +
("Item".boost/${BOOST_MIN}::float)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 2.6)) DESC NULLS LAST, "Item".id DESC`
}
const TOP_ORDER_BY_SATS = 'ORDER BY "Item"."weightedVotes" DESC NULLS LAST, "Item".id DESC'
async function topOrderByWeightedSats (me, models) {
return `ORDER BY ${await orderByNumerator(me, models)} DESC NULLS LAST, "Item".id DESC`
}

View File

@ -1,6 +1,6 @@
import { AuthenticationError } from 'apollo-server-micro'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { getItem } from './item'
import { getItem, filterClause } from './item'
import { getInvoice } from './wallet'
export default {
@ -76,7 +76,8 @@ export default {
FROM "Item"
JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
WHERE p."userId" = $1
AND "Item"."userId" <> $1 AND "Item".created_at <= $2`
AND "Item"."userId" <> $1 AND "Item".created_at <= $2
${await filterClause(me, models)}`
)
} else {
queries.push(
@ -86,6 +87,7 @@ export default {
JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
WHERE p."userId" = $1
AND "Item"."userId" <> $1 AND "Item".created_at <= $2
${await filterClause(me, models)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`
)
@ -96,7 +98,6 @@ export default {
FROM "Item"
WHERE "Item"."userId" = $1
AND "maxBid" IS NOT NULL
AND status <> 'STOPPED'
AND "statusUpdatedAt" <= $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`
@ -129,6 +130,7 @@ export default {
AND "Mention".created_at <= $2
AND "Item"."userId" <> $1
AND (p."userId" IS NULL OR p."userId" <> $1)
${await filterClause(me, models)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`
)
@ -162,18 +164,20 @@ export default {
if (meFull.noteEarning) {
queries.push(
`SELECT id::text, created_at AS "sortTime", FLOOR(msats / 1000) as "earnedSats",
`SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats",
'Earn' AS type
FROM "Earn"
WHERE "userId" = $1
AND created_at <= $2`
AND created_at <= $2
GROUP BY "userId", created_at`
)
}
}
// we do all this crazy subquery stuff to make 'reward' islands
const notifications = await models.$queryRaw(
`SELECT MAX(id) AS id, MAX("sortTime") AS "sortTime", sum("earnedSats") AS "earnedSats", type
`SELECT MAX(id) AS id, MAX("sortTime") AS "sortTime", sum("earnedSats") AS "earnedSats", type,
MIN("sortTime") AS "minSortTime"
FROM
(SELECT *,
CASE
@ -214,6 +218,26 @@ export default {
JobChanged: {
item: async (n, args, { models }) => getItem(n, { id: n.id }, { models })
},
Earn: {
sources: async (n, args, { me, models }) => {
const [sources] = await models.$queryRaw(`
SELECT
FLOOR(sum(msats) FILTER(WHERE type = 'POST') / 1000) AS posts,
FLOOR(sum(msats) FILTER(WHERE type = 'COMMENT') / 1000) AS comments,
FLOOR(sum(msats) FILTER(WHERE type = 'TIP_POST' OR type = 'TIP_COMMENT') / 1000) AS tips
FROM "Earn"
WHERE "userId" = $1 AND created_at <= $2 AND created_at >= $3
`, Number(me.id), new Date(n.sortTime), new Date(n.minSortTime))
sources.posts ||= 0
sources.comments ||= 0
sources.tips ||= 0
if (sources.posts + sources.comments + sources.tips > 0) {
return sources
}
return null
}
},
Mention: {
mention: async (n, args, { models }) => true,
item: async (n, args, { models }) => getItem(n, { id: n.id }, { models })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,6 +13,10 @@ import CommentEdit from './comment-edit'
import Countdown from './countdown'
import { COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants'
import { ignoreClick } from '../lib/clicks'
import { useMe } from './me'
import DontLikeThis from './dont-link-this'
import Flag from '../svgs/flag-fill.svg'
import { Badge } from 'react-bootstrap'
function Parent ({ item, rootText }) {
const ParentFrag = () => (
@ -78,6 +82,7 @@ export default function Comment ({
const [edit, setEdit] = useState()
const [collapse, setCollapse] = useState(false)
const ref = useRef(null)
const me = useMe()
const router = useRouter()
const mine = item.mine
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
@ -105,7 +110,7 @@ export default function Comment ({
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse ? styles.collapsed : ''}`}
>
<div className={`${itemStyles.item} ${styles.item}`}>
<UpVote item={item} className={styles.upvote} />
{item.meDontLike ? <Flag width={24} height={24} className={`${styles.dontLike}`} /> : <UpVote item={item} className={styles.upvote} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'>
<div className={`${itemStyles.other} ${styles.other}`}>
@ -128,6 +133,9 @@ export default function Comment ({
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link>
{includeParent && <Parent item={item} rootText={rootText} />}
{me && !item.meSats && !item.meDontLike && !item.mine && <DontLikeThis id={item.id} />}
{(item.outlawed && <Link href='/outlawed'><a>{' '}<Badge className={itemStyles.newComment} variant={null}>OUTLAWED</Badge></a></Link>) ||
(item.freebie && !item.mine && (me?.greeterMode) && <Link href='/freebie'><a>{' '}<Badge className={itemStyles.newComment} variant={null}>FREEBIE</Badge></a></Link>)}
{canEdit &&
<>
<span> \ </span>

View File

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

View File

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

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 NoteIcon from '../svgs/notification-4-fill.svg'
import { useQuery, gql } from '@apollo/client'
import LightningIcon from '../svgs/bolt.svg'
function WalletSummary ({ me }) {
if (!me) return null
@ -125,7 +126,18 @@ export default function Header ({ sub }) {
setFired(true)
}, [router.asPath])
}
return path !== '/login' && !path.startsWith('/invites') && <Button id='login' onClick={signIn}>login</Button>
return path !== '/login' && !path.startsWith('/invites') &&
<Button
className='align-items-center d-flex pl-2 pr-3'
id='login'
onClick={() => signIn(null, { callbackUrl: window.location.origin + router.asPath })}
>
<LightningIcon
width={17}
height={17}
className='mr-1'
/>login
</Button>
}
}

View File

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

View File

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

View File

@ -11,6 +11,9 @@ import Toc from './table-of-contents'
import PollIcon from '../svgs/bar-chart-horizontal-fill.svg'
import { Badge } from 'react-bootstrap'
import { newComments } from '../lib/new-comments'
import { useMe } from './me'
import DontLikeThis from './dont-link-this'
import Flag from '../svgs/flag-fill.svg'
export function SearchTitle ({ title }) {
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
@ -36,6 +39,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
useState(mine && (Date.now() < editThreshold))
const [wrap, setWrap] = useState(false)
const titleRef = useRef()
const me = useMe()
const [hasNewComments, setHasNewComments] = useState(false)
useEffect(() => {
@ -58,7 +62,9 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
</div>)
: <div />}
<div className={styles.item}>
{item.position ? <Pin width={24} height={24} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
{item.position
? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLike ? <Flag width={24} height={24} className={`${styles.dontLike}`} /> : <UpVote item={item} className={styles.upvote} />}
<div className={styles.hunk}>
<div className={`${styles.main} flex-wrap ${wrap ? 'd-inline' : ''}`}>
<Link href={`/items/${item.id}`} passHref>
@ -104,6 +110,9 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
<Link href={`/items/${item.id}`} passHref>
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link>
{me && !item.meSats && !item.position && !item.meDontLike && !item.mine && <DontLikeThis id={item.id} />}
{(item.outlawed && <Link href='/outlawed'><a>{' '}<Badge className={styles.newComment} variant={null}>OUTLAWED</Badge></a></Link>) ||
(item.freebie && !item.mine && (me?.greeterMode) && <Link href='/freebie'><a>{' '}<Badge className={styles.newComment} variant={null}>FREEBIE</Badge></a></Link>)}
{item.prior &&
<>
<span> \ </span>

View File

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

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

View File

@ -11,6 +11,8 @@ import { useRouter } from 'next/router'
import Link from 'next/link'
import { CURRENCY_SYMBOLS, usePrice } from './price'
import Avatar from './avatar'
import BootstrapForm from 'react-bootstrap/Form'
import Alert from 'react-bootstrap/Alert'
Yup.addMethod(Yup.string, 'or', function (schemas, msg) {
return this.test({
@ -37,7 +39,7 @@ function PriceHint ({ monthly }) {
const { fiatCurrency } = useMe();
const fiatSymbol = CURRENCY_SYMBOLS[fiatCurrency]
if (!price) {
if (!price || !monthly) {
return null
}
const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
@ -50,13 +52,7 @@ function PriceHint ({ monthly }) {
export default function JobForm ({ item, sub }) {
const storageKeyPrefix = item ? undefined : `${sub.name}-job`
const router = useRouter()
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || sub.baseCost))
const [logoId, setLogoId] = useState(item?.uploadId)
const [getAuctionPosition, { data }] = useLazyQuery(gql`
query AuctionPosition($id: ID, $bid: Int!) {
auctionPosition(sub: "${sub.name}", id: $id, bid: $bid)
}`,
{ fetchPolicy: 'network-only' })
const [upsertJob] = useMutation(gql`
mutation upsertJob($id: ID, $title: String!, $company: String!, $location: String,
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int) {
@ -75,8 +71,8 @@ export default function JobForm ({ item, sub }) {
url: Yup.string()
.or([Yup.string().email(), Yup.string().url()], 'invalid url or email')
.required('required'),
maxBid: Yup.number('must be number')
.integer('must be whole').min(sub.baseCost, `must be at least ${sub.baseCost}`)
maxBid: Yup.number().typeError('must be a number')
.integer('must be whole').min(0, 'must be positive')
.required('required'),
location: Yup.string().test(
'no-remote',
@ -88,14 +84,6 @@ export default function JobForm ({ item, sub }) {
})
})
const position = data?.auctionPosition
useEffect(() => {
const initialMaxBid = Number(item?.maxBid || localStorage.getItem(storageKeyPrefix + '-maxBid')) || sub.baseCost
getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
setMonthly(satsMin2Mo(initialMaxBid))
}, [])
return (
<>
<Form
@ -107,7 +95,7 @@ export default function JobForm ({ item, sub }) {
remote: item?.remote || false,
text: item?.text || '',
url: item?.url || '',
maxBid: item?.maxBid || sub.baseCost,
maxBid: item?.maxBid || 0,
stop: false,
start: false
}}
@ -191,36 +179,66 @@ export default function JobForm ({ item, sub }) {
required
clear
/>
<PromoteJob item={item} sub={sub} storageKeyPrefix={storageKeyPrefix} />
{item && <StatusControl item={item} />}
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
</Form>
</>
)
}
function PromoteJob ({ item, sub, storageKeyPrefix }) {
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0))
const [getAuctionPosition, { data }] = useLazyQuery(gql`
query AuctionPosition($id: ID, $bid: Int!) {
auctionPosition(sub: "${sub.name}", id: $id, bid: $bid)
}`,
{ fetchPolicy: 'network-only' })
const position = data?.auctionPosition
useEffect(() => {
const initialMaxBid = Number(item?.maxBid || localStorage.getItem(storageKeyPrefix + '-maxBid')) || 0
getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
setMonthly(satsMin2Mo(initialMaxBid))
}, [])
return (
<AccordianItem
show={item?.maxBid > 0}
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>}
body={
<>
<Input
label={
<div className='d-flex align-items-center'>bid
<Info>
<ol className='font-weight-bold'>
<li>The higher your bid the higher your job will rank</li>
<li>The minimum bid is {sub.baseCost} sats/min</li>
<li>You can increase or decrease your bid, and edit or stop your job at anytime</li>
<li>Your job will be hidden if your wallet runs out of sats and can be unhidden by filling your wallet again</li>
<li>You can increase, decrease, or remove your bid at anytime</li>
<li>You can edit or stop your job at anytime</li>
<li>If you run out of sats, your job will stop being promoted until you fill your wallet again</li>
</ol>
</Info>
<small className='text-muted ml-2'>optional</small>
</div>
}
name='maxBid'
onChange={async (formik, e) => {
if (e.target.value >= sub.baseCost && e.target.value <= 100000000) {
if (e.target.value >= 0 && e.target.value <= 100000000) {
setMonthly(satsMin2Mo(e.target.value))
getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } })
} else {
setMonthly(satsMin2Mo(sub.baseCost))
setMonthly(satsMin2Mo(0))
}
}}
append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>}
hint={<PriceHint monthly={monthly} />}
storageKeyPrefix={storageKeyPrefix}
/>
<><div className='font-weight-bold text-muted'>This bid puts your job in position: {position}</div></>
{item && <StatusControl item={item} />}
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
</Form>
</>
}
/>
)
}
@ -244,7 +262,7 @@ function StatusControl ({ item }) {
</>
)
}
} else {
} else if (item.status === 'STOPPED') {
StatusComp = () => {
return (
<AccordianItem
@ -261,12 +279,13 @@ function StatusControl ({ item }) {
}
return (
<div className='my-2'>
<div className='my-3 border border-3 rounded'>
<div className='p-3'>
<BootstrapForm.Label>job control</BootstrapForm.Label>
{item.status === 'NOSATS' &&
<div className='text-danger font-weight-bold my-1'>
you have no sats! <Link href='/wallet?type=fund' passHref><a className='text-reset text-underline'>fund your wallet</a></Link> to resume your job
</div>}
<Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' passHref><a className='text-reset text-underline'>fund your wallet</a></Link> or reduce bid to continue promoting your job</Alert>}
<StatusComp />
</div>
</div>
)
}

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)' }} />
<div className='ml-2'>
<div className='font-weight-bold text-boost'>
you stacked {n.earnedSats} sats <small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
you stacked {n.earnedSats} sats in rewards<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</div>
{n.sources &&
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
{n.sources.posts > 0 && <span>{n.sources.posts} sats for top posts</span>}
{n.sources.comments > 0 && <span>{n.sources.posts > 0 && ' \\ '}{n.sources.comments} sats for top comments</span>}
{n.sources.tips > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0) && ' \\ '}{n.sources.tips} sats for tipping top content early</span>}
</div>}
<div className='pb-1' style={{ lineHeight: '140%' }}>
SN distributes the sats it earns back to its best users daily. These sats come from <Link href='/~jobs' passHref><a>jobs</a></Link>, boost, and posting fees.
</div>
@ -99,13 +105,15 @@ function Notification ({ n }) {
you were mentioned in
</small>}
{n.__typename === 'JobChanged' &&
<small className={`font-weight-bold text-${n.item.status === 'NOSATS' ? 'danger' : 'success'} ml-1`}>
{n.item.status === 'NOSATS'
? 'your job ran out of sats'
: 'your job is active again'}
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
{n.item.status === 'ACTIVE'
? 'your job is active again'
: (n.item.status === 'NOSATS'
? 'your job promotion ran out of sats'
: 'your job has been stopped')}
</small>}
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}>
{n.item.maxBid
{n.item.isJob
? <ItemJob item={n.item} />
: n.item.title
? <Item item={n.item} />

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { Button, Container } from 'react-bootstrap'
import styles from './search.module.css'
import SearchIcon from '../svgs/search-fill.svg'
import SearchIcon from '../svgs/search-line.svg'
import CloseIcon from '../svgs/close-line.svg'
import { useEffect, useState } from 'react'
import { Form, Input, SubmitButton } from './form'

View File

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

View File

@ -21,10 +21,14 @@ export const ITEM_FIELDS = gql`
boost
path
meSats
meDontLike
outlawed
freebie
ncomments
commentSats
lastCommentAt
maxBid
isJob
company
location
remote
@ -36,7 +40,6 @@ export const ITEM_FIELDS = gql`
status
uploadId
mine
paidImgLink
root {
id
title
@ -67,6 +70,45 @@ export const ITEMS = gql`
}
}`
export const OUTLAWED_ITEMS = gql`
${ITEM_FIELDS}
query outlawedItems($cursor: String) {
outlawedItems(cursor: $cursor) {
cursor
items {
...ItemFields
text
}
}
}`
export const BORDERLAND_ITEMS = gql`
${ITEM_FIELDS}
query borderlandItems($cursor: String) {
borderlandItems(cursor: $cursor) {
cursor
items {
...ItemFields
text
}
}
}`
export const FREEBIE_ITEMS = gql`
${ITEM_FIELDS}
query freebieItems($cursor: String) {
freebieItems(cursor: $cursor) {
cursor
items {
...ItemFields
text
}
}
}`
export const POLL_FIELDS = gql`
fragment PollFields on Item {
poll {

View File

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

View File

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

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

View File

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

View File

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

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 (
<LayoutCenter sub={item.sub?.name}>
{item.maxBid
{item.isJob
? <JobForm item={item} sub={item.sub} />
: (item.url
? <LinkForm item={item} editThreshold={editThreshold} adv />

View File

@ -6,7 +6,7 @@ import { getGetServerSideProps } from '../../../api/ssrApollo'
import { useQuery } from '@apollo/client'
export const getServerSideProps = getGetServerSideProps(ITEM_FULL, null,
data => !data.item || (data.item.status !== 'ACTIVE' && !data.item.mine))
data => !data.item || (data.item.status === 'STOPPED' && !data.item.mine))
export default function AnItem ({ data: { item } }) {
const { data } = useQuery(ITEM_FULL, {

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,
noteInvites: settings?.noteInvites,
noteJobIndicator: settings?.noteJobIndicator,
hideInvoiceDesc: settings?.hideInvoiceDesc
hideInvoiceDesc: settings?.hideInvoiceDesc,
wildWestMode: settings?.wildWestMode,
greeterMode: settings?.greeterMode
}}
schema={SettingsSchema}
onSubmit={async ({ tipDefault, fiatCurrency, ...values }) => {
@ -126,7 +128,7 @@ export default function Settings ({ data: { settings } }) {
<div className='form-label'>privacy</div>
<Checkbox
label={
<>hide invoice descriptions
<div className='d-flex align-items-center'>hide invoice descriptions
<Info>
<ul className='font-weight-bold'>
<li>Use this if you don't want funding sources to be linkable to your SN identity.</li>
@ -138,10 +140,39 @@ export default function Settings ({ data: { settings } }) {
</li>
</ul>
</Info>
</>
</div>
}
name='hideInvoiceDesc'
/>
<div className='form-label'>content</div>
<Checkbox
label={
<div className='d-flex align-items-center'>wild west mode
<Info>
<ul className='font-weight-bold'>
<li>don't hide flagged content</li>
<li>don't down rank flagged content</li>
</ul>
</Info>
</div>
}
name='wildWestMode'
groupClassName='mb-0'
/>
<Checkbox
label={
<div className='d-flex align-items-center'>greeter mode
<Info>
<ul className='font-weight-bold'>
<li>see and screen free posts and comments</li>
<li>help onboard users to SN and Lightning</li>
<li>you might be subject to more spam</li>
</ul>
</Info>
</div>
}
name='greeterMode'
/>
<div className='d-flex'>
<SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton>
</div>

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

View File

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

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

View File

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