diff --git a/.ebextensions/tor.config b/.ebextensions/tor.config index 93bf5146..73e19701 100644 --- a/.ebextensions/tor.config +++ b/.ebextensions/tor.config @@ -13,7 +13,7 @@ files: content: | HTTPTunnelPort 127.0.0.1:7050 SocksPort 0 - Log notice syslog + Log info file /var/log/tor/info.log HiddenServiceDir /var/lib/tor/sn/ HiddenServicePort 80 127.0.0.1:443 services: diff --git a/api/resolvers/growth.js b/api/resolvers/growth.js index 0009ecd1..3b03b385 100644 --- a/api/resolvers/growth.js +++ b/api/resolvers/growth.js @@ -33,10 +33,10 @@ export default { return await models.$queryRaw( `SELECT date_trunc('month', "ItemAct".created_at) AS time, - sum(CASE WHEN act = 'STREAM' THEN sats ELSE 0 END) as jobs, - sum(CASE WHEN act = 'VOTE' AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END) as fees, - sum(CASE WHEN act = 'BOOST' THEN sats ELSE 0 END) as boost, - sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END) as tips + sum(CASE WHEN act = 'STREAM' THEN "ItemAct".sats ELSE 0 END) as jobs, + sum(CASE WHEN act IN ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN "ItemAct".sats ELSE 0 END) as fees, + sum(CASE WHEN act = 'BOOST' THEN "ItemAct".sats ELSE 0 END) as boost, + sum(CASE WHEN act = 'TIP' THEN "ItemAct".sats ELSE 0 END) as tips FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id WHERE date_trunc('month', now_utc()) <> date_trunc('month', "ItemAct".created_at) @@ -63,8 +63,8 @@ export default { `SELECT time, sum(airdrop) as rewards, sum(post) as posts, sum(comment) as comments FROM ((SELECT date_trunc('month', "ItemAct".created_at) AS time, 0 as airdrop, - CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE sats END as comment, - CASE WHEN "Item"."parentId" IS NULL THEN sats ELSE 0 END as post + CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".sats END as comment, + CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".sats ELSE 0 END as post FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId" WHERE date_trunc('month', now_utc()) <> date_trunc('month', "ItemAct".created_at) AND @@ -121,10 +121,10 @@ export default { spentWeekly: async (parent, args, { models }) => { const [stats] = await models.$queryRaw( `SELECT json_build_array( - json_build_object('name', 'jobs', 'value', sum(CASE WHEN act = 'STREAM' THEN sats ELSE 0 END)), - json_build_object('name', 'fees', 'value', sum(CASE WHEN act = 'VOTE' AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END)), - json_build_object('name', 'boost', 'value', sum(CASE WHEN act = 'BOOST' THEN sats ELSE 0 END)), - json_build_object('name', 'tips', 'value', sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END))) as array + json_build_object('name', 'jobs', 'value', sum(CASE WHEN act = 'STREAM' THEN "ItemAct".sats ELSE 0 END)), + json_build_object('name', 'fees', 'value', sum(CASE WHEN act in ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN "ItemAct".sats ELSE 0 END)), + json_build_object('name', 'boost', 'value', sum(CASE WHEN act = 'BOOST' THEN "ItemAct".sats ELSE 0 END)), + json_build_object('name', 'tips', 'value', sum(CASE WHEN act = 'TIP' THEN "ItemAct".sats ELSE 0 END))) as array FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id WHERE "ItemAct".created_at >= now_utc() - interval '1 week'`) @@ -140,8 +140,8 @@ export default { ) as array FROM ((SELECT 0 as airdrop, - CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE sats END as comment, - CASE WHEN "Item"."parentId" IS NULL THEN sats ELSE 0 END as post + CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".sats END as comment, + CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".sats ELSE 0 END as post FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId" WHERE "ItemAct".created_at >= now_utc() - interval '1 week' AND diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 2bb8f0d2..b3de372d 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -4,19 +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 } from '../../lib/constants' +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 } @@ -25,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" @@ -66,8 +69,61 @@ 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 }) => { + if (!me) return 0 + // how many of the parents starting at parentId belong to me + const [{ item_spam: count }] = await models.$queryRaw(`SELECT item_spam($1, $2, '${ITEM_SPAM_INTERVAL}')`, + Number(parentId), Number(me.id)) + + return count + }, items: async (parent, { sub, sort, cursor, name, within }, { me, models }) => { const decodedCursor = decodeCursor(cursor) let items; let user; let pins; let subFull @@ -97,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) @@ -108,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') @@ -119,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 @@ -135,13 +194,24 @@ export default { // we pull from their wallet // TODO: need to filter out by payment status items = await models.$queryRaw(` - ${SELECT} - FROM "Item" - WHERE "parentId" IS NULL AND created_at <= $1 - AND "pinId" IS NULL - ${subClause(3)} - AND status <> 'STOPPED' - ORDER BY (CASE WHEN status = 'ACTIVE' THEN "maxBid" ELSE 0 END) DESC, created_at ASC + SELECT * + FROM ( + (${SELECT} + FROM "Item" + WHERE "parentId" IS NULL AND created_at <= $1 + AND "pinId" IS NULL + ${subClause(3)} + AND status = 'ACTIVE' AND "maxBid" > 0 + ORDER BY "maxBid" DESC, created_at ASC) + UNION ALL + (${SELECT} + FROM "Item" + WHERE "parentId" IS NULL AND created_at <= $1 + AND "pinId" IS NULL + ${subClause(3)} + AND ((status = 'ACTIVE' AND "maxBid" = 0) OR status = 'NOSATS') + ORDER BY created_at DESC) + ) a OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub) break @@ -157,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') } @@ -169,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') } @@ -199,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) @@ -217,6 +344,16 @@ export default { let comments, user switch (sort) { + case 'recent': + comments = await models.$queryRaw(` + ${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) + break case 'user': if (!name) { throw new UserInputError('must supply name', { argumentName: 'name' }) @@ -232,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) @@ -243,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 @@ -293,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) @@ -317,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: [ @@ -390,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 @@ -404,19 +552,26 @@ 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) { where.where.id = { not: Number(id) } } @@ -431,8 +586,7 @@ export default { data.url = ensureProtocol(data.url) if (id) { - const { forward, boost, ...remaining } = data - return await updateItem(parent, { id, data: remaining }, { me, models }) + return await updateItem(parent, { id, data }, { me, models }) } else { return await createItem(parent, data, { me, models }) } @@ -441,13 +595,63 @@ export default { const { id, ...data } = args if (id) { - const { forward, boost, ...remaining } = data - return await updateItem(parent, { id, data: remaining }, { me, models }) + return await updateItem(parent, { id, data }, { me, models }) } else { return await createItem(parent, data, { me, models }) } }, - upsertJob: async (parent, { id, sub, title, company, location, remote, text, url, maxBid, status }, { me, models }) => { + upsertPoll: async (parent, { id, forward, boost, title, text, options }, { me, models }) => { + if (!me) { + throw new AuthenticationError('you must be logged in') + } + + if (boost && boost < BOOST_MIN) { + throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) + } + + let fwdUser + if (forward) { + fwdUser = await models.user.findUnique({ where: { name: forward } }) + if (!fwdUser) { + throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) + } + } + + if (id) { + const optionCount = await models.pollOption.count({ + where: { + itemId: Number(id) + } + }) + + if (options.length + optionCount > MAX_POLL_NUM_CHOICES) { + throw new UserInputError(`total choices must be <${MAX_POLL_NUM_CHOICES}`, { argumentName: 'options' }) + } + + const [item] = await serialize(models, + 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 { + if (options.length < 2 || options.length > MAX_POLL_NUM_CHOICES) { + throw new UserInputError(`choices must be >2 and <${MAX_POLL_NUM_CHOICES}`, { argumentName: 'options' }) + } + + const [item] = await serialize(models, + 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) + + item.comments = [] + return item + } + }, + upsertJob: async (parent, { + id, sub, title, company, location, remote, + text, url, maxBid, status, logo + }, { me, models }) => { if (!me) { throw new AuthenticationError('you must be logged in to create job') } @@ -457,61 +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 - } + 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 }) @@ -519,6 +698,17 @@ export default { updateComment: async (parent, { id, text }, { me, models }) => { return await updateItem(parent, { id, data: { text } }, { me, models }) }, + pollVote: async (parent, { id }, { me, models }) => { + if (!me) { + throw new AuthenticationError('you must be logged in') + } + + await serialize(models, + models.$queryRaw(`${SELECT} FROM poll_vote($1, $2) AS "Item"`, + Number(id), Number(me.id))) + + return id + }, act: async (parent, { id, sats }, { me, models }) => { // need to make sure we are logged in if (!me) { @@ -544,10 +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 @@ -590,6 +801,27 @@ export default { return prior.id }, + poll: async (item, args, { models, me }) => { + if (!item.pollCost) { + return null + } + + const options = await models.$queryRaw` + SELECT "PollOption".id, option, count("PollVote"."userId") as count, + coalesce(bool_or("PollVote"."userId" = ${me?.id}), 'f') as "meVoted" + FROM "PollOption" + LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id + WHERE "PollOption"."itemId" = ${item.id} + GROUP BY "PollOption".id + ORDER BY "PollOption".id ASC + ` + const poll = {} + poll.options = options + poll.meVoted = options.some(o => o.meVoted) + poll.count = options.reduce((t, o) => t + o.count, 0) + + return poll + }, user: async (item, args, { models }) => await models.user.findUnique({ where: { id: item.userId } }), fwdUser: async (item, args, { models }) => { @@ -598,36 +830,11 @@ export default { } return await models.user.findUnique({ where: { id: item.fwdUserId } }) }, - ncomments: async (item, args, { models }) => { - const [{ count }] = await models.$queryRaw` - SELECT count(*) - FROM "Item" - WHERE path <@ text2ltree(${item.path}) AND id != ${Number(item.id)}` - return count || 0 - }, - comments: async (item, args, { models }) => { + comments: async (item, args, { me, models }) => { if (item.comments) { return item.comments } - return comments(models, item.id, 'hot') - }, - sats: async (item, args, { models }) => { - const { sum: { sats } } = await models.itemAct.aggregate({ - sum: { - sats: true - }, - where: { - itemId: Number(item.id), - userId: { - not: Number(item.userId) - }, - act: { - not: 'BOOST' - } - } - }) - - return sats || 0 + return comments(me, models, item.id, 'hot') }, upvotes: async (item, args, { models }) => { const { sum: { sats } } = await models.itemAct.aggregate({ @@ -681,10 +888,24 @@ export default { return sats || 0 }, - meComments: async (item, args, { me, models }) => { - if (!me) return 0 + meDontLike: async (item, args, { me, models }) => { + if (!me) return false - return await models.item.count({ where: { userId: me.id, parentId: item.id } }) + 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 @@ -749,22 +970,39 @@ export const createMentions = async (item, models) => { } } -const updateItem = async (parent, { id, data }, { me, models }) => { +export const updateItem = async (parent, { id, data: { title, url, text, boost, forward, parentId } }, { me, models }) => { // update iff this item belongs to me 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') } - // if it's not the FAQ and older than 10 minutes - if (old.id !== 349 && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) { + // if it's not the FAQ, not their bio, and older than 10 minutes + const user = await models.user.findUnique({ where: { id: me.id } }) + if (old.id !== 349 && user.bioId !== id && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) { throw new UserInputError('item can no longer be editted') } - const item = await models.item.update({ - where: { id: Number(id) }, - data - }) + if (boost && boost < BOOST_MIN) { + throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) + } + + if (!old.parentId && title.length > MAX_TITLE_LENGTH) { + throw new UserInputError('title too long') + } + + let fwdUser + if (forward) { + fwdUser = await models.user.findUnique({ where: { name: forward } }) + if (!fwdUser) { + throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) + } + } + + const [item] = await serialize(models, + models.$queryRaw( + `${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) @@ -780,6 +1018,10 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId } throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) } + if (!parentId && title.length > MAX_TITLE_LENGTH) { + throw new UserInputError('title too long') + } + let fwdUser if (forward) { fwdUser = await models.user.findUnique({ where: { name: forward } }) @@ -789,20 +1031,13 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId } } const [item] = await serialize(models, - models.$queryRaw(`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6) AS "Item"`, - title, url, text, Number(boost || 0), Number(parentId), Number(me.id))) + models.$queryRaw( + `${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))) await createMentions(item, models) - if (fwdUser) { - await models.item.update({ - where: { id: item.id }, - data: { - fwdUserId: fwdUser.id - } - }) - } - item.comments = [] return item } @@ -837,12 +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, 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) + - GREATEST("Item".boost-1000+5, 0)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 4)) DESC NULLS LAST, "Item".id DESC` + 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` +} diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 9f2f3cb7..7e630494 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -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 }) diff --git a/api/resolvers/serial.js b/api/resolvers/serial.js index 3ab93108..773b8bf0 100644 --- a/api/resolvers/serial.js +++ b/api/resolvers/serial.js @@ -29,6 +29,15 @@ async function serialize (models, call) { if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) { bail(new Error('faucet has been revoked or is exhausted')) } + if (error.message.includes('23514')) { + bail(new Error('constraint failure')) + } + if (error.message.includes('SN_INV_PENDING_LIMIT')) { + bail(new Error('too many pending invoices')) + } + if (error.message.includes('SN_INV_EXCEED_BALANCE')) { + bail(new Error('pending invoices must not cause balance to exceed 1m sats')) + } if (error.message.includes('40001')) { throw new Error('wallet balance serialization failure - retry again') } diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index f68c6909..0ec79f84 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -28,7 +28,7 @@ export default { } }) - return latest.createdAt + return latest?.createdAt } } } diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 06207be1..41c3ebea 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -1,6 +1,6 @@ import { AuthenticationError, UserInputError } from 'apollo-server-errors' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' -import { createMentions, getItem, SELECT } from './item' +import { createMentions, getItem, SELECT, updateItem, filterClause } from './item' import serialize from './serial' export function topClause (within) { @@ -133,6 +133,10 @@ export default { cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, users } + }, + searchUsers: async (parent, { name }, { models }) => { + return await models.$queryRaw` + SELECT * FROM users where id > 615 AND SIMILARITY(name, ${name}) > .1 ORDER BY SIMILARITY(name, ${name}) DESC LIMIT 5` } }, @@ -142,6 +146,14 @@ export default { throw new AuthenticationError('you must be logged in') } + if (!/^[\w_]+$/.test(name)) { + throw new UserInputError('only letters, numbers, and _') + } + + if (name.length > 32) { + throw new UserInputError('too long') + } + try { await models.user.update({ where: { id: me.id }, data: { name } }) } catch (error) { @@ -156,9 +168,7 @@ export default { throw new AuthenticationError('you must be logged in') } - await models.user.update({ where: { id: me.id }, data }) - - return true + return await models.user.update({ where: { id: me.id }, data }) }, setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => { if (!me) { @@ -188,22 +198,15 @@ export default { const user = await models.user.findUnique({ where: { id: me.id } }) - let item if (user.bioId) { - item = await models.item.update({ - where: { id: Number(user.bioId) }, - data: { - text: bio - } - }) + await updateItem(parent, { id: user.bioId, data: { text: bio, title: `@${user.name}'s bio` } }, { me, models }) } else { - ([item] = await serialize(models, + const [item] = await serialize(models, models.$queryRaw(`${SELECT} FROM create_bio($1, $2, $3) AS "Item"`, - `@${user.name}'s bio`, bio, Number(me.id)))) + `@${user.name}'s bio`, bio, Number(me.id))) + await createMentions(item, models) } - await createMentions(item, models) - return await models.user.findUnique({ where: { id: me.id } }) }, unlinkAuth: async (parent, { authType }, { models, me }) => { @@ -239,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') @@ -308,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 @@ -330,9 +337,6 @@ export default { const job = await models.item.findFirst({ where: { - status: { - not: 'STOPPED' - }, maxBid: { not: null }, diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 0ae6ec7c..c9724cee 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,29 +1,11 @@ import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service' -import { UserInputError, AuthenticationError, ForbiddenError } from 'apollo-server-micro' +import { UserInputError, AuthenticationError } from 'apollo-server-micro' import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import lnpr from 'bolt11' import { SELECT } from './item' import { lnurlPayDescriptionHash } from '../../lib/lnurl' -const INVOICE_LIMIT = 10 - -export async function belowInvoiceLimit (models, userId) { - // make sure user has not exceeded INVOICE_LIMIT - const count = await models.invoice.count({ - where: { - userId, - expiresAt: { - gt: new Date() - }, - confirmedAt: null, - cancelled: false - } - }) - - return count < INVOICE_LIMIT -} - export async function getInvoice (parent, { id }, { me, models }) { if (!me) { throw new AuthenticationError('you must be logged in') @@ -121,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')) { @@ -199,16 +182,12 @@ export default { const user = await models.user.findUnique({ where: { id: me.id } }) - if (!await belowInvoiceLimit(models, me.id)) { - throw new ForbiddenError('too many pending invoices') - } - // set expires at to 3 hours into future const expiresAt = new Date(new Date().setHours(new Date().getHours() + 3)) const description = `${amount} sats for @${user.name} on stacker.news` try { const invoice = await createInvoice({ - description, + description: user.hideInvoiceDesc ? undefined : description, lnd, tokens: amount, expires_at: expiresAt diff --git a/api/ssrApollo.js b/api/ssrApollo.js index ca9acb3c..3335cde1 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -35,8 +35,29 @@ export default async function getSSRApolloClient (req, me = null) { export function getGetServerSideProps (query, variables = null, notFoundFunc, requireVar) { return async function ({ req, query: params }) { + const { nodata, ...realParams } = params + const vars = { ...realParams, ...variables } const client = await getSSRApolloClient(req) - const vars = { ...params, ...variables } + + const { data: { me } } = await client.query({ + query: ME_SSR + }) + + const price = await getPrice() + + // we want to use client-side cache + if (nodata && query) { + return { + props: { + me, + price, + apollo: { + query: print(query), + variables: vars + } + } + } + } if (requireVar && !vars[requireVar]) { return { @@ -60,17 +81,11 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re props = { apollo: { query: print(query), - variables: { ...params, ...variables } + variables: vars } } } - const { data: { me } } = await client.query({ - query: ME_SSR - }) - - const price = await getPrice() - return { props: { ...props, diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 642069a1..2ee07a68 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -11,6 +11,10 @@ export default gql` allItems(cursor: String): Items 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 { @@ -21,10 +25,27 @@ export default gql` extend type Mutation { upsertLink(id: ID, title: String!, url: String!, boost: Int, forward: String): Item! upsertDiscussion(id: ID, title: String!, text: String, boost: Int, forward: String): Item! - upsertJob(id: ID, sub: ID!, title: String!, company: String!, location: String, remote: Boolean, text: String!, url: String!, maxBid: Int!, status: String): Item! + upsertJob(id: ID, sub: ID!, title: String!, company: String!, location: String, remote: Boolean, + text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! + 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! + } + + type PollOption { + id: ID, + option: String! + count: Int! + meVoted: Boolean! + } + + type Poll { + meVoted: Boolean! + count: Int! + options: [PollOption!]! } type Items { @@ -57,19 +78,28 @@ export default gql` mine: Boolean! boost: Int! sats: Int! + commentSats: Int! + lastCommentAt: String upvotes: Int! meSats: Int! - meComments: Int! + meDontLike: Boolean! + outlawed: Boolean! + freebie: Boolean! + paidImgLink: Boolean ncomments: Int! comments: [Item!]! path: String position: Int prior: Int maxBid: Int + isJob: Boolean! + pollCost: Int + poll: Poll company: String location: String remote: Boolean sub: Sub status: String + uploadId: Int } ` diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 89261359..d880ba98 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -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 { diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 1214eb57..8e580219 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -8,6 +8,7 @@ export default gql` users: [User!] nameAvailable(name: String!): Boolean! topUsers(cursor: String, within: String!, userType: String!): TopUsers + searchUsers(name: String!): [User!]! } type Users { @@ -30,7 +31,8 @@ export default gql` setName(name: String!): Boolean setSettings(tipDefault: Int!, noteItemSats: Boolean!, noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!, - noteInvites: Boolean!, noteJobIndicator: Boolean!): Boolean + 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 @@ -70,6 +72,9 @@ export default gql` noteDeposits: Boolean! noteInvites: Boolean! noteJobIndicator: Boolean! + hideInvoiceDesc: Boolean! + wildWestMode: Boolean! + greeterMode: Boolean! lastCheckedJobs: String authMethods: AuthMethods! } diff --git a/components/action-tooltip.js b/components/action-tooltip.js index f79158e8..5d64eabb 100644 --- a/components/action-tooltip.js +++ b/components/action-tooltip.js @@ -1,7 +1,7 @@ import { useFormikContext } from 'formik' import { OverlayTrigger, Tooltip } from 'react-bootstrap' -export default function ActionTooltip ({ children, notForm, disable, overlayText }) { +export default function ActionTooltip ({ children, notForm, disable, overlayText, placement }) { // if we're in a form, we want to hide tooltip on submit let formik if (!notForm) { @@ -12,7 +12,7 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText } return ( {overlayText || '1 sat'} diff --git a/components/adv-post-form.js b/components/adv-post-form.js index 10bb272b..99626863 100644 --- a/components/adv-post-form.js +++ b/components/adv-post-form.js @@ -1,14 +1,22 @@ import AccordianItem from './accordian-item' import * as Yup from 'yup' -import { Input } from './form' +import { Input, InputUserSuggest } from './form' import { InputGroup } from 'react-bootstrap' import { BOOST_MIN } from '../lib/constants' import { NAME_QUERY } from '../fragments/users' +import Info from './info' export function AdvPostSchema (client) { return { boost: Yup.number().typeError('must be a number') - .min(BOOST_MIN, `must be blank or at least ${BOOST_MIN}`).integer('must be whole'), + .min(BOOST_MIN, `must be blank or at least ${BOOST_MIN}`).integer('must be whole').test({ + name: 'boost', + test: async boost => { + if (!boost || boost % BOOST_MIN === 0) return true + return false + }, + message: `must be divisble be ${BOOST_MIN}` + }), forward: Yup.string() .test({ name: 'name', @@ -22,28 +30,50 @@ export function AdvPostSchema (client) { } } -export const AdvPostInitial = { - boost: '', - forward: '' +export function AdvPostInitial ({ forward }) { + return { + boost: '', + forward: forward || '' + } } -export default function AdvPostForm () { +export default function AdvPostForm ({ edit }) { return ( options} body={ <> boost} + label={ +
{edit ? 'add boost' : 'boost'} + +
    +
  1. Boost ranks posts higher temporarily based on the amount
  2. +
  3. The minimum boost is {BOOST_MIN} sats
  4. +
  5. Each {BOOST_MIN} sats of boost is equivalent to one trusted upvote +
      +
    • e.g. {BOOST_MIN * 2} sats is like 2 votes
    • +
    +
  6. +
  7. The decay of boost "votes" increases at 2x the rate of organic votes +
      +
    • i.e. boost votes fall out of ranking faster
    • +
    +
  8. +
  9. 100% of sats from boost are given back to top users as rewards
  10. +
+
+
+ } name='boost' hint={ranks posts higher temporarily based on the amount} append={sats} /> - forward sats to} name='forward' hint={100% of sats will be sent to this user} - prepend=@ + prepend={@} showValid /> diff --git a/components/avatar.js b/components/avatar.js new file mode 100644 index 00000000..d9fd394a --- /dev/null +++ b/components/avatar.js @@ -0,0 +1,74 @@ +import { useRef, useState } from 'react' +import AvatarEditor from 'react-avatar-editor' +import { Button, Modal, Form as BootstrapForm } from 'react-bootstrap' +import Upload from './upload' +import EditImage from '../svgs/image-edit-fill.svg' +import Moon from '../svgs/moon-fill.svg' + +export default function Avatar ({ onSuccess }) { + const [uploading, setUploading] = useState() + const [editProps, setEditProps] = useState() + const ref = useRef() + const [scale, setScale] = useState(1) + + return ( + <> + setEditProps(null)} + > +
setEditProps(null)}>X
+ + + + setScale(parseFloat(e.target.value))} + min={1} max={2} step='0.05' + defaultValue={scale} custom + /> + + + +
+ +
+ {uploading + ? + : } +
} + onError={e => { + console.log(e) + setUploading(false) + }} + onSelect={(file, upload) => { + setEditProps({ file, upload }) + }} + onSuccess={async key => { + onSuccess && onSuccess(key) + setUploading(false) + }} + onStarted={() => { + setUploading(true) + }} + /> + + ) +} diff --git a/components/comment-edit.js b/components/comment-edit.js index a803f548..3d78b375 100644 --- a/components/comment-edit.js +++ b/components/comment-edit.js @@ -3,6 +3,7 @@ import * as Yup from 'yup' import { gql, useMutation } from '@apollo/client' import styles from './reply.module.css' import TextareaAutosize from 'react-textarea-autosize' +import { EditFeeButton } from './fee-button' export const CommentSchema = Yup.object({ text: Yup.string().required('required').trim() @@ -53,7 +54,10 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc autoFocus required /> - save + ) diff --git a/components/comment.js b/components/comment.js index db840e80..157ba5e8 100644 --- a/components/comment.js +++ b/components/comment.js @@ -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,11 +110,11 @@ export default function Comment ({ ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse ? styles.collapsed : ''}`} >
- + {item.meDontLike ? : }
- {item.sats} sats + {item.sats} sats \ {item.boost > 0 && <> @@ -117,7 +122,7 @@ export default function Comment ({ \ } - {item.ncomments} replies + {item.ncomments} replies \ @@ -128,6 +133,9 @@ export default function Comment ({ {timeSince(new Date(item.createdAt))} {includeParent && } + {me && !item.meSats && !item.meDontLike && !item.mine && } + {(item.outlawed && {' '}OUTLAWED) || + (item.freebie && !item.mine && (me?.greeterMode) && {' '}FREEBIE)} {canEdit && <> \ @@ -186,7 +194,7 @@ export default function Comment ({
{!noReply && } {children}
diff --git a/components/comment.module.css b/components/comment.module.css index cd205557..047a00d4 100644 --- a/components/comment.module.css +++ b/components/comment.module.css @@ -1,12 +1,21 @@ .item { align-items: flex-start; margin-bottom: 0 !important; + padding-bottom: 0 !important; } .upvote { margin-top: 9px; } +.dontLike { + fill: #a5a5a5; + margin-right: .2rem; + padding: 2px; + margin-left: 1px; + margin-top: 9px; +} + .text { margin-top: .1rem; padding-right: 15px; @@ -77,7 +86,6 @@ } .hunk { - overflow: visible; margin-bottom: 0; margin-top: 0.15rem; } diff --git a/components/comments.js b/components/comments.js index 283d3414..db5dfe2b 100644 --- a/components/comments.js +++ b/components/comments.js @@ -6,7 +6,7 @@ import { Nav, Navbar } from 'react-bootstrap' import { COMMENTS_QUERY } from '../fragments/items' import { COMMENTS } from '../fragments/comments' -export function CommentsHeader ({ handleSort }) { +export function CommentsHeader ({ handleSort, commentSats }) { const [sort, setSort] = useState('hot') const getHandleClick = sort => { @@ -17,52 +17,60 @@ export function CommentsHeader ({ handleSort }) { } return ( - + ) } -export default function Comments ({ parentId, comments, ...props }) { +export default function Comments ({ parentId, commentSats, comments, ...props }) { const client = useApolloClient() useEffect(() => { const hash = window.location.hash if (hash) { - document.querySelector(hash).scrollIntoView({ behavior: 'smooth' }) + try { + document.querySelector(hash).scrollIntoView({ behavior: 'smooth' }) + } catch {} } }, []) - const [getComments, { loading }] = useLazyQuery(COMMENTS_QUERY, { + const [loading, setLoading] = useState() + const [getComments] = useLazyQuery(COMMENTS_QUERY, { fetchPolicy: 'network-only', onCompleted: data => { client.writeFragment({ @@ -80,12 +88,20 @@ export default function Comments ({ parentId, comments, ...props }) { comments: data.comments } }) + setLoading(false) } }) return ( <> - {comments.length ? getComments({ variables: { id: parentId, sort } })} /> : null} + {comments.length + ? { + setLoading(true) + getComments({ variables: { id: parentId, sort } }) + }} + /> + : null} {loading ? : comments.map(item => ( diff --git a/components/discussion-form.js b/components/discussion-form.js index 82a62374..611d97d3 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -2,11 +2,11 @@ import { Form, Input, MarkdownInput, SubmitButton } from '../components/form' import { useRouter } from 'next/router' import * as Yup from 'yup' import { gql, useApolloClient, useMutation } from '@apollo/client' -import ActionTooltip from '../components/action-tooltip' 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 FeeButton, { EditFeeButton } from './fee-button' export function DiscussionForm ({ item, editThreshold, titleLabel = 'title', @@ -15,6 +15,7 @@ export function DiscussionForm ({ }) { const router = useRouter() const client = useApolloClient() + // const me = useMe() const [upsertDiscussion] = useMutation( gql` mutation upsertDiscussion($id: ID, $title: String!, $text: String, $boost: Int, $forward: String) { @@ -31,12 +32,15 @@ export function DiscussionForm ({ ...AdvPostSchema(client) }) + // const cost = linkOrImg ? 10 : me?.freePosts ? 0 : 1 + return (
{ @@ -60,6 +64,7 @@ export function DiscussionForm ({ name='title' required autoFocus + clear />
: null} /> - {!item && adv && } - - {item ? 'save' : buttonText} - + {adv && } +
+ {item + ? + : } +
) } diff --git a/components/dont-link-this.js b/components/dont-link-this.js new file mode 100644 index 00000000..1ed60658 --- /dev/null +++ b/components/dont-link-this.js @@ -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 ( + + + + + + + { + try { + await dontLikeThis({ + variables: { id }, + optimisticResponse: { dontLikeThis: true } + }) + } catch (error) { + if (error.toString().includes('insufficient funds')) { + setError(true) + } + } + }} + > + flag + + + + ) +} diff --git a/components/fee-button.js b/components/fee-button.js new file mode 100644 index 00000000..2677bde8 --- /dev/null +++ b/components/fee-button.js @@ -0,0 +1,119 @@ +import { Table } from 'react-bootstrap' +import ActionTooltip from './action-tooltip' +import Info from './info' +import styles from './fee-button.module.css' +import { gql, useQuery } from '@apollo/client' +import { useFormikContext } from 'formik' + +function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { + return ( + + + + + + + {hasImgLink && + + + + } + {repetition > 0 && + + + + } + {boost > 0 && + + + + } + + + + + + + +
{baseFee} sats{parentId ? 'reply' : 'post'} fee
x 10image/link fee
x 10{repetition}{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m
+ {boost} satsboost
{cost} satstotal fee
+ ) +} + +export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow }) { + const query = parentId + ? gql`{ itemRepetition(parentId: "${parentId}") }` + : gql`{ itemRepetition }` + const { data } = useQuery(query, { pollInterval: 1000 }) + const repetition = data?.itemRepetition || 0 + const formik = useFormikContext() + const boost = formik?.values?.boost || 0 + const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost) + + const show = alwaysShow || !formik?.isSubmitting + return ( +
+ + {text}{cost > baseFee && show && {cost} sats} + + {cost > baseFee && show && + + + } +
+ ) +} + +function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) { + return ( + + + {addImgLink && + <> + + + + + + + + + + + + + } + {boost > 0 && + + + + } + + + + + + + +
{paidSats} sats{parentId ? 'reply' : 'post'} fee
x 10image/link fee
- {paidSats} satsalready paid
+ {boost} satsboost
{cost} satstotal fee
+ ) +} + +export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, variant, text, alwaysShow, parentId }) { + const formik = useFormikContext() + const boost = formik?.values?.boost || 0 + const addImgLink = hasImgLink && !hadImgLink + const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost) + + const show = alwaysShow || !formik?.isSubmitting + return ( +
+ + {text}{cost > 0 && show && {cost} sats} + + {cost > 0 && show && + + + } +
+ ) +} diff --git a/components/fee-button.module.css b/components/fee-button.module.css new file mode 100644 index 00000000..87c27ed9 --- /dev/null +++ b/components/fee-button.module.css @@ -0,0 +1,15 @@ +.receipt { + background-color: var(--theme-inputBg); + max-width: 250px; + margin: auto; + table-layout: auto; + width: 100%; +} + +.receipt td { + padding: .25rem .1rem; +} + +.receipt tfoot { + border-top: 2px solid var(--theme-borderColor); +} \ No newline at end of file diff --git a/components/footer.js b/components/footer.js index 10d1e675..7c043b79 100644 --- a/components/footer.js +++ b/components/footer.js @@ -34,7 +34,7 @@ const COLORS = { grey: '#707070', link: '#007cbe', linkHover: '#004a72', - linkVisited: '#7acaf5' + linkVisited: '#537587' }, dark: { body: '#000000', @@ -53,7 +53,7 @@ const COLORS = { grey: '#969696', link: '#2e99d1', linkHover: '#007cbe', - linkVisited: '#066ba3' + linkVisited: '#56798E' } } @@ -96,7 +96,7 @@ const AnalyticsPopover = ( visitors \ - + users @@ -129,41 +129,48 @@ export default function Footer ({ noLinks }) {