Merge branch 'master' into master

This commit is contained in:
Keyan 2022-10-04 12:30:54 -05:00 committed by GitHub
commit 1c45f651eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
127 changed files with 3948 additions and 839 deletions

View File

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

View File

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

View File

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

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

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

View File

@ -28,7 +28,7 @@ export default {
}
})
return latest.createdAt
return latest?.createdAt
}
}
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -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 (
<OverlayTrigger
placement='bottom'
placement={placement || 'bottom'}
overlay={
<Tooltip>
{overlayText || '1 sat'}

View File

@ -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 (
<AccordianItem
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
body={
<>
<Input
label={<>boost</>}
label={
<div className='d-flex align-items-center'>{edit ? 'add boost' : 'boost'}
<Info>
<ol className='font-weight-bold'>
<li>Boost ranks posts higher temporarily based on the amount</li>
<li>The minimum boost is {BOOST_MIN} sats</li>
<li>Each {BOOST_MIN} sats of boost is equivalent to one trusted upvote
<ul>
<li>e.g. {BOOST_MIN * 2} sats is like 2 votes</li>
</ul>
</li>
<li>The decay of boost "votes" increases at 2x the rate of organic votes
<ul>
<li>i.e. boost votes fall out of ranking faster</li>
</ul>
</li>
<li>100% of sats from boost are given back to top users as rewards</li>
</ol>
</Info>
</div>
}
name='boost'
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Input
<InputUserSuggest
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
/>
</>

74
components/avatar.js Normal file
View File

@ -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 (
<>
<Modal
show={!!editProps}
onHide={() => setEditProps(null)}
>
<div className='modal-close' onClick={() => setEditProps(null)}>X</div>
<Modal.Body className='text-right mt-1 p-4'>
<AvatarEditor
ref={ref} width={200} height={200}
image={editProps?.file}
scale={scale}
style={{
width: '100%',
height: 'auto'
}}
/>
<BootstrapForm.Group controlId='formBasicRange'>
<BootstrapForm.Control
type='range' onChange={e => setScale(parseFloat(e.target.value))}
min={1} max={2} step='0.05'
defaultValue={scale} custom
/>
</BootstrapForm.Group>
<Button onClick={() => {
ref.current.getImageScaledToCanvas().toBlob(blob => {
if (blob) {
editProps.upload(blob)
setEditProps(null)
}
}, 'image/jpeg')
}}
>save
</Button>
</Modal.Body>
</Modal>
<Upload
as={({ onClick }) =>
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
{uploading
? <Moon className='fill-white spin' />
: <EditImage className='fill-white' />}
</div>}
onError={e => {
console.log(e)
setUploading(false)
}}
onSelect={(file, upload) => {
setEditProps({ file, upload })
}}
onSuccess={async key => {
onSuccess && onSuccess(key)
setUploading(false)
}}
onStarted={() => {
setUploading(true)
}}
/>
</>
)
}

View File

@ -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
/>
<SubmitButton variant='secondary' className='mt-1'>save</SubmitButton>
<EditFeeButton
paidSats={comment.meSats}
parentId={comment.parentId} text='save' ChildButton={SubmitButton} variant='secondary'
/>
</Form>
</div>
)

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,11 +110,11 @@ 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}`}>
<span title={`from ${item.upvotes} users (${item.meSats} from me)`}>{item.sats} sats</span>
<span title={`from ${item.upvotes} users ${item.mine ? `\\ ${item.meSats} sats to post` : `(${item.meSats} sats from me)`}`}>{item.sats} sats</span>
<span> \ </span>
{item.boost > 0 &&
<>
@ -117,7 +122,7 @@ export default function Comment ({
<span> \ </span>
</>}
<Link href={`/items/${item.id}`} passHref>
<a className='text-reset'>{item.ncomments} replies</a>
<a title={`${item.commentSats} sats`} className='text-reset'>{item.ncomments} replies</a>
</Link>
<span> \ </span>
<Link href={`/${item.user.name}`} passHref>
@ -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>
@ -186,7 +194,7 @@ export default function Comment ({
<div className={`${styles.children}`}>
{!noReply &&
<Reply
depth={depth + 1} parentId={item.id} meComments={item.meComments} replyOpen={replyOpen}
depth={depth + 1} item={item} replyOpen={replyOpen}
/>}
{children}
<div className={`${styles.comments} ml-sm-1 ml-md-3`}>

View File

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

View File

@ -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 (
<Navbar className='py-1'>
<Navbar className='pt-1 pb-0'>
<Nav
className={styles.navbarNav}
activeKey={sort}
>
<Nav.Item>
<Nav.Link
eventKey='hot'
className={styles.navLink}
onClick={getHandleClick('hot')}
>
hot
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link
eventKey='recent'
className={styles.navLink}
onClick={getHandleClick('recent')}
>
recent
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link
eventKey='top'
className={styles.navLink}
onClick={getHandleClick('top')}
>
top
</Nav.Link>
<Nav.Item className='text-muted'>
{commentSats} sats
</Nav.Item>
<div className='ml-auto d-flex'>
<Nav.Item>
<Nav.Link
eventKey='hot'
className={styles.navLink}
onClick={getHandleClick('hot')}
>
hot
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link
eventKey='recent'
className={styles.navLink}
onClick={getHandleClick('recent')}
>
recent
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link
eventKey='top'
className={styles.navLink}
onClick={getHandleClick('top')}
>
top
</Nav.Link>
</Nav.Item>
</div>
</Nav>
</Navbar>
)
}
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 ? <CommentsHeader handleSort={sort => getComments({ variables: { id: parentId, sort } })} /> : null}
{comments.length
? <CommentsHeader
commentSats={commentSats} handleSort={sort => {
setLoading(true)
getComments({ variables: { id: parentId, sort } })
}}
/>
: null}
{loading
? <CommentsSkeleton />
: comments.map(item => (

View File

@ -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 (
<Form
initial={{
title: item?.title || '',
text: item?.text || '',
...AdvPostInitial
suggest: '',
...AdvPostInitial({ forward: item?.fwdUser?.name })
}}
schema={DiscussionSchema}
onSubmit={handleSubmit || (async ({ boost, ...values }) => {
@ -60,6 +64,7 @@ export function DiscussionForm ({
name='title'
required
autoFocus
clear
/>
<MarkdownInput
topLevel
@ -71,10 +76,18 @@ export function DiscussionForm ({
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
: null}
/>
{!item && adv && <AdvPostForm />}
<ActionTooltip>
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : buttonText}</SubmitButton>
</ActionTooltip>
{adv && <AdvPostForm edit={!!item} />}
<div className='mt-3'>
{item
? <EditFeeButton
paidSats={item.meSats}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/>
: <FeeButton
baseFee={1} parentId={null} text={buttonText}
ChildButton={SubmitButton} variant='secondary'
/>}
</div>
</Form>
)
}

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

119
components/fee-button.js Normal file
View File

@ -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 (
<Table className={styles.receipt} borderless size='sm'>
<tbody>
<tr>
<td>{baseFee} sats</td>
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
</tr>
{hasImgLink &&
<tr>
<td>x 10</td>
<td align='right' className='font-weight-light'>image/link fee</td>
</tr>}
{repetition > 0 &&
<tr>
<td>x 10<sup>{repetition}</sup></td>
<td className='font-weight-light' align='right'>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</td>
</tr>}
{boost > 0 &&
<tr>
<td>+ {boost} sats</td>
<td className='font-weight-light' align='right'>boost</td>
</tr>}
</tbody>
<tfoot>
<tr>
<td className='font-weight-bold'>{cost} sats</td>
<td align='right' className='font-weight-light'>total fee</td>
</tr>
</tfoot>
</Table>
)
}
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 (
<div className='d-flex align-items-center'>
<ActionTooltip overlayText={`${cost} sats`}>
<ChildButton variant={variant}>{text}{cost > baseFee && show && <small> {cost} sats</small>}</ChildButton>
</ActionTooltip>
{cost > baseFee && show &&
<Info>
<Receipt baseFee={baseFee} hasImgLink={hasImgLink} repetition={repetition} cost={cost} parentId={parentId} boost={boost} />
</Info>}
</div>
)
}
function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) {
return (
<Table className={styles.receipt} borderless size='sm'>
<tbody>
{addImgLink &&
<>
<tr>
<td>{paidSats} sats</td>
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
</tr>
<tr>
<td>x 10</td>
<td align='right' className='font-weight-light'>image/link fee</td>
</tr>
<tr>
<td>- {paidSats} sats</td>
<td align='right' className='font-weight-light'>already paid</td>
</tr>
</>}
{boost > 0 &&
<tr>
<td>+ {boost} sats</td>
<td className='font-weight-light' align='right'>boost</td>
</tr>}
</tbody>
<tfoot>
<tr>
<td className='font-weight-bold'>{cost} sats</td>
<td align='right' className='font-weight-light'>total fee</td>
</tr>
</tfoot>
</Table>
)
}
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 (
<div className='d-flex align-items-center'>
<ActionTooltip overlayText={`${cost} sats`}>
<ChildButton variant={variant}>{text}{cost > 0 && show && <small> {cost} sats</small>}</ChildButton>
</ActionTooltip>
{cost > 0 && show &&
<Info>
<EditReceipt paidSats={paidSats} addImgLink={addImgLink} cost={cost} parentId={parentId} boost={boost} />
</Info>}
</div>
)
}

View File

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

View File

@ -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
</a>
<span className='mx-2 text-dark'> \ </span>
<Link href='/users/forever' passHref>
<Link href='/users/week' passHref>
<a className='text-dark d-inline-flex'>
users
</a>
@ -129,41 +129,48 @@ export default function Footer ({ noLinks }) {
<footer>
<Container className='mb-3 mt-4'>
{!noLinks &&
<div className='mb-2' style={{ fontWeight: 500 }}>
<>
{mounted &&
<div className='mb-2'>
{darkMode.value
? <Sun onClick={() => darkMode.toggle()} className='fill-grey theme' />
: <Moon onClick={() => darkMode.toggle()} className='fill-grey theme' />}
</div>}
<Link href='/faq' passHref>
<a className='nav-link p-0 d-inline-flex'>
faq
<div className='mb-2' style={{ fontWeight: 500 }}>
<OverlayTrigger trigger='click' placement='top' overlay={AnalyticsPopover} rootClose>
<div className='nav-link p-0 d-inline-flex' style={{ cursor: 'pointer' }}>
analytics
</div>
</OverlayTrigger>
<span className='mx-2 text-muted'> \ </span>
<OverlayTrigger trigger='click' placement='top' overlay={ChatPopover} rootClose>
<div className='nav-link p-0 d-inline-flex' style={{ cursor: 'pointer' }}>
chat
</div>
</OverlayTrigger>
</div>
<div className='mb-2' style={{ fontWeight: 500 }}>
<Link href='/faq' passHref>
<a className='nav-link p-0 d-inline-flex'>
faq
</a>
</Link>
<span className='mx-2 text-muted'> \ </span>
<Link href='/story' passHref>
<a className='nav-link p-0 d-inline-flex'>
story
</a>
</Link>
<span className='mx-2 text-muted'> \ </span>
<a href='/privacy' className='nav-link p-0 d-inline-flex' target='_blank'>
privacy
</a>
</Link>
<span className='mx-2 text-muted'> \ </span>
<Link href='/story' passHref>
<a className='nav-link p-0 d-inline-flex'>
story
<span className='mx-2 text-muted'> \ </span>
<a href='/rss' className='nav-link p-0 d-inline-flex' target='_blank'>
rss
</a>
</Link>
<span className='mx-2 text-muted'> \ </span>
<OverlayTrigger trigger='click' placement='top' overlay={AnalyticsPopover} rootClose>
<div className='nav-link p-0 d-inline-flex' style={{ cursor: 'pointer' }}>
analytics
</div>
</OverlayTrigger>
<span className='mx-2 text-muted'> \ </span>
<OverlayTrigger trigger='click' placement='top' overlay={ChatPopover} rootClose>
<div className='nav-link p-0 d-inline-flex' style={{ cursor: 'pointer' }}>
chat
</div>
</OverlayTrigger>
<span className='mx-2 text-muted'> \ </span>
<a href='/rss' className='nav-link p-0 d-inline-flex' target='_blank'>
rss
</a>
</div>}
</div>
</>}
{data &&
<div
className={`text-small mx-auto mb-1 ${styles.connect}`}
@ -173,6 +180,7 @@ export default function Footer ({ noLinks }) {
size='sm'
groupClassName='mb-0 w-100'
readOnly
noForm
placeholder={data.connectAddress}
/>
</div>}

View File

@ -2,14 +2,19 @@ import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import BootstrapForm from 'react-bootstrap/Form'
import Alert from 'react-bootstrap/Alert'
import { Formik, Form as FormikForm, useFormikContext, useField } from 'formik'
import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik'
import React, { useEffect, useRef, useState } from 'react'
import copy from 'clipboard-copy'
import Thumb from '../svgs/thumb-up-fill.svg'
import { Nav } from 'react-bootstrap'
import { Col, Dropdown, Nav } from 'react-bootstrap'
import Markdown from '../svgs/markdown-line.svg'
import styles from './form.module.css'
import Text from '../components/text'
import AddIcon from '../svgs/add-fill.svg'
import { mdHas } from '../lib/md'
import CloseIcon from '../svgs/close-line.svg'
import { useLazyQuery } from '@apollo/client'
import { USER_SEARCH } from '../fragments/users'
export function SubmitButton ({
children, variant, value, onClick, ...props
@ -71,7 +76,7 @@ export function InputSkeleton ({ label, hint }) {
)
}
export function MarkdownInput ({ label, topLevel, groupClassName, ...props }) {
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, ...props }) {
const [tab, setTab] = useState('write')
const [, meta] = useField(props)
@ -98,7 +103,12 @@ export function MarkdownInput ({ label, topLevel, groupClassName, ...props }) {
</Nav>
<div className={tab !== 'write' ? 'd-none' : ''}>
<InputInner
{...props}
{...props} onChange={(formik, e) => {
if (onChange) onChange(formik, e)
if (setHasImgLink) {
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
}
}}
/>
</div>
<div className={tab !== 'preview' ? 'd-none' : 'form-group'}>
@ -122,10 +132,10 @@ function FormGroup ({ className, label, children }) {
function InputInner ({
prepend, append, hint, showValid, onChange, overrideValue,
innerRef, storageKeyPrefix, ...props
innerRef, storageKeyPrefix, noForm, clear, onKeyDown, ...props
}) {
const [field, meta, helpers] = props.readOnly ? [{}, {}, {}] : useField(props)
const formik = props.readOnly ? null : useFormikContext()
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
const formik = noForm ? null : useFormikContext()
const storageKey = storageKeyPrefix ? storageKeyPrefix + '-' + props.name : undefined
@ -145,6 +155,8 @@ function InputInner ({
}
}, [overrideValue])
const invalid = meta.touched && meta.error
return (
<>
<InputGroup hasValidation>
@ -158,6 +170,7 @@ function InputInner ({
if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) {
formik?.submitForm()
}
if (onKeyDown) onKeyDown(e)
}}
ref={innerRef}
{...field} {...props}
@ -172,11 +185,23 @@ function InputInner ({
onChange(formik, e)
}
}}
isInvalid={meta.touched && meta.error}
isInvalid={invalid}
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
/>
{append && (
{(append || (clear && field.value)) && (
<InputGroup.Append>
{(clear && field.value) &&
<Button
variant={null}
onClick={() => {
helpers.setValue('')
if (storageKey) {
localStorage.removeItem(storageKey)
}
}}
className={`${styles.clearButton} ${invalid ? styles.isInvalid : ''}`}
><CloseIcon className='fill-grey' height={20} width={20} />
</Button>}
{append}
</InputGroup.Append>
)}
@ -193,6 +218,76 @@ function InputInner ({
)
}
export function InputUserSuggest ({ label, groupClassName, ...props }) {
const [getSuggestions] = useLazyQuery(USER_SEARCH, {
fetchPolicy: 'network-only',
onCompleted: data => {
setSuggestions({ array: data.searchUsers, index: 0 })
}
})
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
const [ovalue, setOValue] = useState()
return (
<FormGroup label={label} className={groupClassName}>
<InputInner
{...props}
autoComplete='off'
onChange={(_, e) => getSuggestions({ variables: { name: e.target.value } })}
overrideValue={ovalue}
onKeyDown={(e) => {
switch (e.code) {
case 'ArrowUp':
e.preventDefault()
setSuggestions(
{
...suggestions,
index: Math.max(suggestions.index - 1, 0)
})
break
case 'ArrowDown':
e.preventDefault()
setSuggestions(
{
...suggestions,
index: Math.min(suggestions.index + 1, suggestions.array.length - 1)
})
break
case 'Enter':
e.preventDefault()
setOValue(suggestions.array[suggestions.index].name)
setSuggestions(INITIAL_SUGGESTIONS)
break
case 'Escape':
e.preventDefault()
setSuggestions(INITIAL_SUGGESTIONS)
break
default:
break
}
}}
/>
<Dropdown show={suggestions.array.length > 0}>
<Dropdown.Menu className={styles.suggestionsMenu}>
{suggestions.array.map((v, i) =>
<Dropdown.Item
key={v.name}
active={suggestions.index === i}
onClick={() => {
setOValue(v.name)
setSuggestions(INITIAL_SUGGESTIONS)
}}
>
{v.name}
</Dropdown.Item>)}
</Dropdown.Menu>
</Dropdown>
</FormGroup>
)
}
export function Input ({ label, groupClassName, ...props }) {
return (
<FormGroup label={label} className={groupClassName}>
@ -201,6 +296,39 @@ export function Input ({ label, groupClassName, ...props }) {
)
}
export function VariableInput ({ label, groupClassName, name, hint, max, readOnlyLen, ...props }) {
return (
<FormGroup label={label} className={groupClassName}>
<FieldArray name={name}>
{({ form, ...fieldArrayHelpers }) => {
const options = form.values[name]
return (
<>
{options.map((_, i) => (
<div key={i}>
<BootstrapForm.Row className='mb-2'>
<Col>
<InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i > 1 ? 'optional' : undefined} />
</Col>
{options.length - 1 === i && options.length !== max
? <AddIcon className='fill-grey align-self-center pointer mx-2' onClick={() => fieldArrayHelpers.push('')} />
: null}
</BootstrapForm.Row>
</div>
))}
</>
)
}}
</FieldArray>
{hint && (
<BootstrapForm.Text>
{hint}
</BootstrapForm.Text>
)}
</FormGroup>
)
}
export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, handleChange, inline, ...props }) {
// React treats radios and checkbox inputs differently other input types, select, and textarea.
// Formik does this too! When you specify `type` to useField(), it will
@ -243,11 +371,17 @@ export function Form ({
validationSchema={schema}
initialTouched={validateImmediately && initial}
validateOnBlur={false}
onSubmit={async (...args) =>
onSubmit && onSubmit(...args).then(() => {
onSubmit={async (values, ...args) =>
onSubmit && onSubmit(values, ...args).then(() => {
if (!storageKeyPrefix) return
Object.keys(...args).forEach(v =>
localStorage.removeItem(storageKeyPrefix + '-' + v))
Object.keys(values).forEach(v => {
localStorage.removeItem(storageKeyPrefix + '-' + v)
if (Array.isArray(values[v])) {
values[v].forEach(
(_, i) => localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`))
}
}
)
}).catch(e => setError(e.message || e))}
>
<FormikForm {...props} noValidate>

View File

@ -10,3 +10,22 @@
margin-top: -1px;
height: auto;
}
.clearButton {
background-color: var(--theme-inputBg);
border: 1px solid var(--theme-borderColor);
padding: 0rem 0.5rem;
border-left: 0;
display: flex;
align-items: center;
}
.clearButton.isInvalid {
border-color: #c03221;
}
/* https://github.com/react-bootstrap/react-bootstrap/issues/5475 */
.suggestionsMenu {
opacity: 1 !important;
pointer-events: unset !important;
}

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
@ -27,6 +28,8 @@ export default function Header ({ sub }) {
const [fired, setFired] = useState()
const me = useMe()
const prefix = sub ? `/~${sub}` : ''
// there's always at least 2 on the split, e.g. '/' yields ['','']
const topNavKey = path.split('/')[sub ? 2 : 1]
const { data: subLatestPost } = useQuery(gql`
query subLatestPost($name: ID!) {
subLatestPost(name: $name)
@ -53,7 +56,7 @@ export default function Header ({ sub }) {
<link rel='shortcut icon' href={me?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
</Head>
<Link href='/notifications' passHref>
<Nav.Link className='pl-0 position-relative'>
<Nav.Link eventKey='notifications' className='pl-0 position-relative'>
<NoteIcon />
{me?.hasNewNotes &&
<span className={styles.notification}>
@ -65,12 +68,12 @@ export default function Header ({ sub }) {
<NavDropdown
className={styles.dropdown} title={
<Link href={`/${me?.name}`} passHref>
<Nav.Link className='p-0' onClick={e => e.preventDefault()}>{`@${me?.name}`}</Nav.Link>
<Nav.Link eventKey={me?.name} as='div' className='p-0' onClick={e => e.preventDefault()}>{`@${me?.name}`}</Nav.Link>
</Link>
} alignRight
>
<Link href={'/' + me?.name} passHref>
<NavDropdown.Item>
<NavDropdown.Item eventKey={me?.name}>
profile
{me && !me.bioId &&
<div className='p-1 d-inline-block bg-secondary ml-1'>
@ -79,14 +82,14 @@ export default function Header ({ sub }) {
</NavDropdown.Item>
</Link>
<Link href='/wallet' passHref>
<NavDropdown.Item>wallet</NavDropdown.Item>
<NavDropdown.Item eventKey='wallet'>wallet</NavDropdown.Item>
</Link>
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref>
<NavDropdown.Item>satistics</NavDropdown.Item>
<NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
</Link>
<NavDropdown.Divider />
<Link href='/invites' passHref>
<NavDropdown.Item>invites
<NavDropdown.Item eventKey='invites'>invites
{me && !me.hasInvites &&
<div className='p-1 d-inline-block bg-success ml-1'>
<span className='invisible'>{' '}</span>
@ -96,7 +99,7 @@ export default function Header ({ sub }) {
<NavDropdown.Divider />
<div className='d-flex align-items-center'>
<Link href='/settings' passHref>
<NavDropdown.Item>settings</NavDropdown.Item>
<NavDropdown.Item eventKey='settings'>settings</NavDropdown.Item>
</Link>
</div>
<NavDropdown.Divider />
@ -110,7 +113,7 @@ export default function Header ({ sub }) {
{me &&
<Nav.Item>
<Link href='/wallet' passHref>
<Nav.Link className='text-success px-0 text-nowrap'><WalletSummary me={me} /></Nav.Link>
<Nav.Link eventKey='wallet' className='text-success px-0 text-nowrap'><WalletSummary me={me} /></Nav.Link>
</Link>
</Nav.Item>}
</div>
@ -123,22 +126,36 @@ 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>
}
}
const showJobIndicator = sub !== 'jobs' && (!me || me.noteJobIndicator) &&
(!lastCheckedJobs || lastCheckedJobs < subLatestPost?.subLatestPost)
const NavItems = ({ className }) => {
return (
<>
<Nav.Item className={className}>
<Link href={prefix + '/recent'} passHref>
<Nav.Link className={styles.navLink}>recent</Nav.Link>
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
</Link>
</Nav.Item>
{!prefix &&
<Nav.Item className={className}>
<Link href='/top/posts/week' passHref>
<Nav.Link className={styles.navLink}>top</Nav.Link>
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
</Link>
</Nav.Item>}
<Nav.Item className={className}>
@ -148,7 +165,7 @@ export default function Header ({ sub }) {
jobs
</Nav.Link>
</Link>
{sub !== 'jobs' && (!me || me.noteJobIndicator) && (!lastCheckedJobs || lastCheckedJobs < subLatestPost?.subLatestPost) &&
{showJobIndicator &&
<span className={styles.jobIndicator}>
<span className='invisible'>{' '}</span>
</span>}
@ -157,7 +174,7 @@ export default function Header ({ sub }) {
{me &&
<Nav.Item className={className}>
<Link href={prefix + '/post'} passHref>
<Nav.Link className={styles.navLinkButton}>post</Nav.Link>
<Nav.Link eventKey='post' className={styles.navLinkButton}>post</Nav.Link>
</Link>
</Nav.Item>}
</>
@ -170,7 +187,7 @@ export default function Header ({ sub }) {
<Navbar className='pb-0 pb-md-1'>
<Nav
className={styles.navbarNav}
activeKey={path}
activeKey={topNavKey}
>
<div className='d-flex'>
<Link href='/' passHref>
@ -194,7 +211,7 @@ export default function Header ({ sub }) {
<Navbar className='pt-0 pb-1 d-md-none'>
<Nav
className={`${styles.navbarNav} justify-content-around`}
activeKey={path}
activeKey={topNavKey}
>
<NavItems />
</Nav>

View File

@ -16,7 +16,13 @@ export default function Info ({ children }) {
{children}
</Modal.Body>
</Modal>
<InfoIcon width={18} height={18} className='fill-theme-color pointer ml-1' onClick={() => setInfo(true)} />
<InfoIcon
width={18} height={18} className='fill-theme-color pointer ml-1'
onClick={(e) => {
e.preventDefault()
setInfo(true)
}}
/>
</>
)
}

View File

@ -21,7 +21,7 @@ export default function Invite ({ invite, active }) {
<CopyInput
groupClassName='mb-1'
size='sm' type='text'
placeholder={`https://stacker.news/invites/${invite.id}`} readOnly
placeholder={`https://stacker.news/invites/${invite.id}`} readOnly noForm
/>
<div className={styles.other}>
<span>{invite.gift} sat gift</span>

View File

@ -1,4 +1,5 @@
import Item, { ItemJob } from './item'
import Item from './item'
import ItemJob from './item-job'
import Reply from './reply'
import Comment from './comment'
import Text from './text'
@ -10,7 +11,9 @@ import { Button } from 'react-bootstrap'
import { TwitterTweetEmbed } from 'react-twitter-embed'
import YouTube from 'react-youtube'
import useDarkMode from 'use-dark-mode'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import Poll from './poll'
import { commentsViewed } from '../lib/new-comments'
function BioItem ({ item, handleClick }) {
const me = useMe()
@ -29,7 +32,7 @@ function BioItem ({ item, handleClick }) {
>edit bio
</Button>
</div>}
<Reply parentId={item.id} meComments={item.meComments} />
<Reply item={item} />
</>
)
}
@ -80,13 +83,14 @@ function ItemEmbed ({ item }) {
}
function TopLevelItem ({ item, noReply, ...props }) {
const ItemComponent = item.maxBid ? ItemJob : Item
const ItemComponent = item.isJob ? ItemJob : Item
return (
<ItemComponent item={item} showFwdUser {...props}>
<ItemComponent item={item} toc showFwdUser {...props}>
{item.text && <ItemText item={item} />}
{item.url && <ItemEmbed item={item} />}
{!noReply && <Reply parentId={item.id} meComments={item.meComments} replyOpen />}
{item.poll && <Poll item={item} />}
{!noReply && <Reply item={item} replyOpen />}
</ItemComponent>
)
}
@ -96,6 +100,10 @@ function ItemText ({ item }) {
}
export default function ItemFull ({ item, bio, ...props }) {
useEffect(() => {
commentsViewed(item)
}, [item.lastCommentAt])
return (
<>
{item.parentId
@ -109,7 +117,7 @@ export default function ItemFull ({ item, bio, ...props }) {
</div>)}
{item.comments &&
<div className={styles.comments}>
<Comments parentId={item.id} comments={item.comments} />
<Comments parentId={item.id} commentSats={item.commentSats} comments={item.comments} />
</div>}
</>
)

91
components/item-job.js Normal file
View File

@ -0,0 +1,91 @@
import * as Yup from 'yup'
import Toc from './table-of-contents'
import { Button, Image } from 'react-bootstrap'
import { SearchTitle } from './item'
import styles from './item.module.css'
import Link from 'next/link'
import { timeSince } from '../lib/time'
import EmailIcon from '../svgs/mail-open-line.svg'
export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = Yup.string().email().isValidSync(item.url)
return (
<>
{rank
? (
<div className={`${styles.rank} pb-2 align-self-center`}>
{rank}
</div>)
: <div />}
<div className={`${styles.item}`}>
<Link href={`/items/${item.id}`} passHref>
<a>
<Image
src={item.uploadId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${item.uploadId}` : '/jobs-default.png'} width='42' height='42' className={styles.companyImage}
/>
</a>
</Link>
<div className={`${styles.hunk} align-self-center mb-0`}>
<div className={`${styles.main} flex-wrap d-inline`}>
<Link href={`/items/${item.id}`} passHref>
<a className={`${styles.title} text-reset mr-2`}>
{item.searchTitle
? <SearchTitle title={item.searchTitle} />
: (
<>{item.title}</>)}
</a>
</Link>
</div>
<div className={`${styles.other}`}>
{item.company &&
<>
{item.company}
</>}
{(item.location || item.remote) &&
<>
<span> \ </span>
{`${item.location || ''}${item.location && item.remote ? ' or ' : ''}${item.remote ? 'Remote' : ''}`}
</>}
<wbr />
<span> \ </span>
<span>
<Link href={`/${item.user.name}`} passHref>
<a>@{item.user.name}</a>
</Link>
<span> </span>
<Link href={`/items/${item.id}`} passHref>
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link>
</span>
{item.mine &&
<>
<wbr />
<span> \ </span>
<Link href={`/items/${item.id}/edit`} passHref>
<a className='text-reset'>
edit
</a>
</Link>
{item.status !== 'ACTIVE' && <span className='ml-1 font-weight-bold text-boost'> {item.status}</span>}
</>}
</div>
</div>
{toc && <Toc text={item.text} />}
</div>
{children && (
<div className={`${styles.children}`} style={{ marginLeft: 'calc(42px + .8rem)' }}>
<div className='mb-3 d-flex'>
<Button
target='_blank' href={isEmail ? `mailto:${item.url}?subject=${encodeURIComponent(item.title)} via Stacker News` : item.url}
>
apply {isEmail && <EmailIcon className='ml-1' />}
</Button>
{isEmail && <div className='ml-3 align-self-center text-muted font-weight-bold'>{item.url}</div>}
</div>
{children}
</div>
)}
</>
)
}

View File

@ -7,98 +7,20 @@ import Countdown from './countdown'
import { NOFOLLOW_LIMIT } from '../lib/constants'
import Pin from '../svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace'
import { formatSats } from '../lib/format'
import * as Yup from 'yup'
import Briefcase from '../svgs/briefcase-4-fill.svg'
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'
function SearchTitle ({ title }) {
export function SearchTitle ({ title }) {
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
return <mark key={`mark-${match}`}>{match}</mark>
})
}
export function ItemJob ({ item, rank, children }) {
const isEmail = Yup.string().email().isValidSync(item.url)
return (
<>
{rank
? (
<div className={styles.rank}>
{rank}
</div>)
: <div />}
<div className={`${styles.item} ${item.status === 'NOSATS' && !item.mine ? styles.itemDead : ''}`}>
<Briefcase width={24} height={24} className={styles.case} />
<div className={styles.hunk}>
<div className={`${styles.main} flex-wrap d-inline`}>
<Link href={`/items/${item.id}`} passHref>
<a className={`${styles.title} text-reset mr-2`}>
{item.searchTitle
? <SearchTitle title={item.searchTitle} />
: (
<>{item.title}
{item.company &&
<>
<span> \ </span>
{item.company}
</>}
{(item.location || item.remote) &&
<>
<span> \ </span>
{`${item.location || ''}${item.location && item.remote ? ' or ' : ''}${item.remote ? 'Remote' : ''}`}
</>}
</>)}
</a>
</Link>
{/* eslint-disable-next-line */}
<a
className={`${styles.link}`}
target='_blank' href={(isEmail ? 'mailto:' : '') + item.url}
>
apply
</a>
</div>
<div className={`${styles.other}`}>
{item.status !== 'NOSATS'
? <span>{formatSats(item.maxBid)} sats per min</span>
: <span>expired</span>}
<span> \ </span>
<Link href={`/items/${item.id}`} passHref>
<a className='text-reset'>{item.ncomments} comments</a>
</Link>
<span> \ </span>
<span>
<Link href={`/${item.user.name}`} passHref>
<a>@{item.user.name}</a>
</Link>
<span> </span>
<Link href={`/items/${item.id}`} passHref>
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link>
</span>
{item.mine &&
<>
<span> \ </span>
<Link href={`/items/${item.id}/edit`} passHref>
<a className='text-reset'>
edit
</a>
</Link>
{item.status !== 'ACTIVE' && <span className='font-weight-bold text-danger'> {item.status}</span>}
</>}
</div>
</div>
</div>
{children && (
<div className={`${styles.children}`}>
{children}
</div>
)}
</>
)
}
function FwdUser ({ user }) {
return (
<div className={styles.other}>
@ -110,13 +32,15 @@ function FwdUser ({ user }) {
)
}
export default function Item ({ item, rank, showFwdUser, children }) {
export default function Item ({ item, rank, showFwdUser, toc, children }) {
const mine = item.mine
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
const [canEdit, setCanEdit] =
useState(mine && (Date.now() < editThreshold))
const [wrap, setWrap] = useState(false)
const titleRef = useRef()
const me = useMe()
const [hasNewComments, setHasNewComments] = useState(false)
useEffect(() => {
setWrap(
@ -124,6 +48,11 @@ export default function Item ({ item, rank, showFwdUser, children }) {
titleRef.current.clientHeight)
}, [])
useEffect(() => {
// if we are showing toc, then this is a full item
setHasNewComments(!toc && newComments(item))
}, [item])
return (
<>
{rank
@ -133,12 +62,15 @@ export default function Item ({ item, rank, showFwdUser, 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>
<a ref={titleRef} className={`${styles.title} text-reset mr-2`}>
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
{item.pollCost && <span> <PollIcon className='fill-grey vertical-align-baseline' height={14} width={14} /></span>}
</a>
</Link>
{item.url &&
@ -155,7 +87,7 @@ export default function Item ({ item, rank, showFwdUser, children }) {
<div className={`${styles.other}`}>
{!item.position &&
<>
<span title={`from ${item.upvotes} users (${item.meSats} sats from me)`}>{item.sats} sats</span>
<span title={`from ${item.upvotes} users ${item.mine ? `\\ ${item.meSats} sats to post` : `(${item.meSats} sats from me)`} `}>{item.sats} sats</span>
<span> \ </span>
</>}
{item.boost > 0 &&
@ -164,7 +96,10 @@ export default function Item ({ item, rank, showFwdUser, children }) {
<span> \ </span>
</>}
<Link href={`/items/${item.id}`} passHref>
<a className='text-reset'>{item.ncomments} comments</a>
<a title={`${item.commentSats} sats`} className='text-reset'>
{item.ncomments} comments
{hasNewComments && <>{' '}<Badge className={styles.newComment} variant={null}>new</Badge></>}
</a>
</Link>
<span> \ </span>
<span>
@ -175,6 +110,9 @@ export default function Item ({ item, rank, showFwdUser, 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>
@ -202,6 +140,7 @@ export default function Item ({ item, rank, showFwdUser, children }) {
</div>
{showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />}
</div>
{toc && <Toc text={item.text} />}
</div>
{children && (
<div className={styles.children}>

View File

@ -4,6 +4,10 @@
max-width: 100%;
}
a.title:visited {
color: var(--theme-grey) !important;
}
.upvote {
margin-top: 3px;
}
@ -16,11 +20,24 @@
flex: 1 0 128px;
}
.newComment {
color: var(--theme-grey) !important;
background: var(--theme-clickToContextColor) !important;
vertical-align: middle;
}
.pin {
fill: #a5a5a5;
margin-right: .2rem;
}
.dontLike {
fill: #a5a5a5;
margin-right: .2rem;
padding: 2px;
margin-left: 1px;
}
.case {
fill: #a5a5a5;
margin-right: .2rem;
@ -41,13 +58,20 @@ a.link:visited {
.other {
font-size: 80%;
color: var(--theme-grey);
margin-bottom: .15rem;
}
.item {
display: flex;
justify-content: flex-start;
min-width: 0;
padding-bottom: .45rem;
}
.item .companyImage {
border-radius: 100%;
align-self: center;
margin-right: 0.5rem;
margin-left: 0.3rem;
}
.itemDead {
@ -60,12 +84,19 @@ a.link:visited {
}
.hunk {
overflow: hidden;
min-width: 0;
width: 100%;
margin-bottom: .3rem;
line-height: 1.06rem;
}
/* .itemJob .hunk {
align-self: center;
}
.itemJob .rank {
align-self: center;
} */
.main {
display: flex;
align-items: baseline;

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

@ -1,5 +1,6 @@
import { useQuery } from '@apollo/client'
import Item, { ItemJob, ItemSkeleton } from './item'
import Item, { ItemSkeleton } from './item'
import ItemJob from './item-job'
import styles from './items.module.css'
import { ITEMS } from '../fragments/items'
import MoreFooter from './more-footer'
@ -27,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>
@ -41,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

@ -1,6 +1,6 @@
import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form'
import TextareaAutosize from 'react-textarea-autosize'
import { InputGroup, Form as BForm, Col } from 'react-bootstrap'
import { InputGroup, Form as BForm, Col, Image } from 'react-bootstrap'
import * as Yup from 'yup'
import { useEffect, useState } from 'react'
import Info from './info'
@ -10,6 +10,9 @@ import { useLazyQuery, gql, useMutation } from '@apollo/client'
import { useRouter } from 'next/router'
import Link from 'next/link'
import { 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({
@ -33,7 +36,7 @@ function satsMin2Mo (minute) {
function PriceHint ({ monthly }) {
const price = usePrice()
if (!price) {
if (!price || !monthly) {
return null
}
const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
@ -46,18 +49,13 @@ 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 [getAuctionPosition, { data }] = useLazyQuery(gql`
query AuctionPosition($id: ID, $bid: Int!) {
auctionPosition(sub: "${sub.name}", id: $id, bid: $bid)
}`,
{ fetchPolicy: 'network-only' })
const [logoId, setLogoId] = useState(item?.uploadId)
const [upsertJob] = useMutation(gql`
mutation upsertJob($id: ID, $title: String!, $company: String!, $location: String,
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String) {
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int) {
upsertJob(sub: "${sub.name}", id: $id, title: $title, company: $company,
location: $location, remote: $remote, text: $text,
url: $url, maxBid: $maxBid, status: $status) {
url: $url, maxBid: $maxBid, status: $status, logo: $logo) {
id
}
}`
@ -69,9 +67,9 @@ export default function JobForm ({ item, sub }) {
text: Yup.string().required('required').trim(),
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}`)
.required('required'),
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',
@ -83,14 +81,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
@ -102,7 +92,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
}}
@ -122,6 +112,7 @@ export default function JobForm ({ item, sub }) {
sub: sub.name,
maxBid: Number(maxBid),
status,
logo: Number(logoId),
...values
}
})
@ -136,22 +127,34 @@ export default function JobForm ({ item, sub }) {
}
})}
>
<div className='form-group'>
<label className='form-label'>logo</label>
<div className='position-relative' style={{ width: 'fit-content' }}>
<Image
src={logoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${logoId}` : '/jobs-default.png'} width='135' height='135' roundedCircle
/>
<Avatar onSuccess={setLogoId} />
</div>
</div>
<Input
label='job title'
name='title'
required
autoFocus
clear
/>
<Input
label='company'
name='company'
required
clear
/>
<BForm.Row className='mr-0'>
<Col>
<Input
label='location'
name='location'
clear
/>
</Col>
<Checkbox
@ -171,33 +174,9 @@ export default function JobForm ({ item, sub }) {
label={<>how to apply <small className='text-muted ml-2'>url or email address</small></>}
name='url'
required
clear
/>
<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>
</ol>
</Info>
</div>
}
name='maxBid'
onChange={async (formik, e) => {
if (e.target.value >= sub.baseCost && e.target.value <= 100000000) {
setMonthly(satsMin2Mo(e.target.value))
getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } })
} else {
setMonthly(satsMin2Mo(sub.baseCost))
}
}}
append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>}
hint={<PriceHint monthly={monthly} />}
/>
<><div className='font-weight-bold text-muted'>This bid puts your job in position: {position}</div></>
<PromoteJob item={item} sub={sub} storageKeyPrefix={storageKeyPrefix} />
{item && <StatusControl item={item} />}
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
</Form>
@ -205,6 +184,61 @@ export default function JobForm ({ item, sub }) {
)
}
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>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 >= 0 && e.target.value <= 100000000) {
setMonthly(satsMin2Mo(e.target.value))
getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } })
} else {
setMonthly(satsMin2Mo(0))
}
}}
append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>}
hint={<PriceHint monthly={monthly} />}
storageKeyPrefix={storageKeyPrefix}
/>
<><div className='font-weight-bold text-muted'>This bid puts your job in position: {position}</div></>
</>
}
/>
)
}
function StatusControl ({ item }) {
let StatusComp
@ -225,7 +259,7 @@ function StatusControl ({ item }) {
</>
)
}
} else {
} else if (item.status === 'STOPPED') {
StatusComp = () => {
return (
<AccordianItem
@ -242,12 +276,13 @@ function StatusControl ({ item }) {
}
return (
<div className='my-2'>
{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>}
<StatusComp />
<div className='my-3 border border-3 rounded'>
<div className='p-3'>
<BootstrapForm.Label>job control</BootstrapForm.Label>
{item.status === 'NOSATS' &&
<Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' 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

@ -2,13 +2,13 @@ import { Form, Input, SubmitButton } from '../components/form'
import { useRouter } from 'next/router'
import * as Yup from 'yup'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import ActionTooltip from '../components/action-tooltip'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
import { ITEM_FIELDS } from '../fragments/items'
import Item from './item'
import AccordianItem from './accordian-item'
import { MAX_TITLE_LENGTH } from '../lib/constants'
import FeeButton, { EditFeeButton } from './fee-button'
// eslint-disable-next-line
const URL = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
@ -55,7 +55,7 @@ export function LinkForm ({ item, editThreshold }) {
initial={{
title: item?.title || '',
url: item?.url || '',
...AdvPostInitial
...AdvPostInitial({ forward: item?.fwdUser?.name })
}}
schema={LinkSchema}
onSubmit={async ({ boost, title, ...values }) => {
@ -78,12 +78,14 @@ export function LinkForm ({ item, editThreshold }) {
name='title'
overrideValue={data?.pageTitle}
required
clear
/>
<Input
label='url'
name='url'
required
autoFocus
clear
hint={editThreshold
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
: null}
@ -98,10 +100,18 @@ export function LinkForm ({ item, editThreshold }) {
})
}}
/>
{!item && <AdvPostForm />}
<ActionTooltip>
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
</ActionTooltip>
<AdvPostForm edit={!!item} />
<div className='mt-3'>
{item
? <EditFeeButton
paidSats={item.meSats}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/>
: <FeeButton
baseFee={1} parentId={null} text='post'
ChildButton={SubmitButton} variant='secondary'
/>}
</div>
{dupesData?.dupes?.length > 0 &&
<div className='mt-3'>
<AccordianItem

View File

@ -26,7 +26,7 @@ export default function LnQR ({ value, webLn, statusVariant, status }) {
/>
</a>
<div className='mt-3 w-100'>
<CopyInput type='text' placeholder={value} readOnly />
<CopyInput type='text' placeholder={value} readOnly noForm />
</div>
<InvoiceStatus variant={statusVariant} status={status} />
</>

View File

@ -7,10 +7,10 @@ export const MeContext = React.createContext({
})
export function MeProvider ({ me, children }) {
const { data } = useQuery(ME, { pollInterval: 1000 })
const { data } = useQuery(ME, { pollInterval: 1000, fetchPolicy: 'cache-and-network' })
const contextValue = {
me: data ? data.me : me
me: data?.me || me
}
return (

View File

@ -28,7 +28,7 @@ export default function MoreFooter ({ cursor, fetchMore, Skeleton, noMoreText })
)
} else {
Footer = () => (
<div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>{noMoreText || 'GENISIS'}</div>
<div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>{noMoreText || 'GENESIS'}</div>
)
}

View File

@ -1,6 +1,7 @@
import { useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment'
import Item, { ItemJob } from './item'
import Item from './item'
import ItemJob from './item-job'
import { NOTIFICATIONS } from '../fragments/notifications'
import { useRouter } from 'next/router'
import MoreFooter from './more-footer'
@ -73,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>
@ -98,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} />

107
components/poll-form.js Normal file
View File

@ -0,0 +1,107 @@
import { Form, Input, MarkdownInput, SubmitButton, VariableInput } from '../components/form'
import { useRouter } from 'next/router'
import * as Yup from 'yup'
import { gql, useApolloClient, useMutation } from '@apollo/client'
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 FeeButton, { EditFeeButton } from './fee-button'
export function PollForm ({ item, editThreshold }) {
const router = useRouter()
const client = useApolloClient()
const [upsertPoll] = useMutation(
gql`
mutation upsertPoll($id: ID, $title: String!, $text: String,
$options: [String!]!, $boost: Int, $forward: String) {
upsertPoll(id: $id, title: $title, text: $text,
options: $options, boost: $boost, forward: $forward) {
id
}
}`
)
const PollSchema = Yup.object({
title: Yup.string().required('required').trim()
.max(MAX_TITLE_LENGTH,
({ max, value }) => `${Math.abs(max - value.length)} too many`),
options: Yup.array().of(
Yup.string().trim().test('my-test', 'required', function (value) {
return (this.path !== 'options[0]' && this.path !== 'options[1]') || value
}).max(MAX_POLL_CHOICE_LENGTH,
({ max, value }) => `${Math.abs(max - value.length)} too many`)
),
...AdvPostSchema(client)
})
const initialOptions = item?.poll?.options.map(i => i.option)
return (
<Form
initial={{
title: item?.title || '',
text: item?.text || '',
options: initialOptions || ['', ''],
...AdvPostInitial({ forward: item?.fwdUser?.name })
}}
schema={PollSchema}
onSubmit={async ({ boost, title, options, ...values }) => {
const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
const { error } = await upsertPoll({
variables: {
id: item?.id,
boost: Number(boost),
title: title.trim(),
options: optionsFiltered,
...values
}
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
await router.push('/recent')
}
}}
storageKeyPrefix={item ? undefined : 'poll'}
>
<Input
label='title'
name='title'
required
/>
<MarkdownInput
topLevel
label={<>text <small className='text-muted ml-2'>optional</small></>}
name='text'
as={TextareaAutosize}
minRows={2}
/>
<VariableInput
label='choices'
name='options'
readOnlyLen={initialOptions?.length}
max={MAX_POLL_NUM_CHOICES}
hint={editThreshold
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
: null}
/>
<AdvPostForm edit={!!item} />
<div className='mt-3'>
{item
? <EditFeeButton
paidSats={item.meSats}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/>
: <FeeButton
baseFee={1} parentId={null} text='post'
ChildButton={SubmitButton} variant='secondary'
/>}
</div>
</Form>
)
}

99
components/poll.js Normal file
View File

@ -0,0 +1,99 @@
import { gql, useMutation } from '@apollo/client'
import { Button } from 'react-bootstrap'
import { fixedDecimal } from '../lib/format'
import { timeLeft } from '../lib/time'
import { useMe } from './me'
import styles from './poll.module.css'
import Check from '../svgs/checkbox-circle-fill.svg'
import { signIn } from 'next-auth/client'
import { useFundError } from './fund-error'
import ActionTooltip from './action-tooltip'
export default function Poll ({ item }) {
const me = useMe()
const { setError } = useFundError()
const [pollVote] = useMutation(
gql`
mutation pollVote($id: ID!) {
pollVote(id: $id)
}`, {
update (cache, { data: { pollVote } }) {
cache.modify({
id: `Item:${item.id}`,
fields: {
poll (existingPoll) {
const poll = { ...existingPoll }
poll.meVoted = true
poll.count += 1
return poll
}
}
})
cache.modify({
id: `PollOption:${pollVote}`,
fields: {
count (existingCount) {
return existingCount + 1
},
meVoted () {
return true
}
}
})
}
}
)
const PollButton = ({ v }) => {
return (
<ActionTooltip placement='left' notForm>
<Button
variant='outline-info' className={styles.pollButton}
onClick={me
? async () => {
try {
await pollVote({
variables: { id: v.id },
optimisticResponse: {
pollVote: v.id
}
})
} catch (error) {
if (error.toString().includes('insufficient funds')) {
setError(true)
}
}
}
: signIn}
>
{v.option}
</Button>
</ActionTooltip>
)
}
const expiresIn = timeLeft(new Date(+new Date(item.createdAt) + 864e5))
const mine = item.user.id === me?.id
return (
<div className={styles.pollBox}>
{item.poll.options.map(v =>
expiresIn && !item.poll.meVoted && !mine
? <PollButton key={v.id} v={v} />
: <PollResult
key={v.id} v={v}
progress={item.poll.count ? fixedDecimal(v.count * 100 / item.poll.count, 1) : 0}
/>)}
<div className='text-muted mt-1'>{item.poll.count} votes \ {expiresIn ? `${expiresIn} left` : 'poll ended'}</div>
</div>
)
}
function PollResult ({ v, progress }) {
return (
<div className={styles.pollResult}>
<span className={styles.pollOption}>{v.option}{v.meVoted && <Check className='fill-grey ml-1 align-self-center' width={18} height={18} />}</span>
<span className='ml-auto mr-2 align-self-center'>{progress}%</span>
<div className={styles.pollProgress} style={{ width: `${progress}%` }} />
</div>
)
}

View File

@ -0,0 +1,45 @@
.pollButton {
margin-top: .25rem;
display: block;
border: 2px solid var(--info);
border-radius: 2rem;
width: 100%;
max-width: 600px;
padding: 0rem 1.1rem;
height: 2rem;
text-transform: uppercase;
}
.pollBox {
padding-top: .5rem;
padding-right: 15px;
width: 100%;
max-width: 600px;
}
.pollResult {
text-transform: uppercase;
position: relative;
width: 100%;
max-width: 600px;
height: 2rem;
margin-top: .25rem;
display: flex;
border-radius: .4rem;
}
.pollProgress {
content: '\A';
border-radius: .4rem 0rem 0rem .4rem;
position: absolute;
background: var(--theme-clickToContextColor);
top: 0;
bottom: 0;
left: 0;
}
.pollResult .pollOption {
align-self: center;
margin-left: .5rem;
display: flex;
}

View File

@ -1,6 +1,7 @@
import React, { useContext, useEffect, useState } from 'react'
import { Button } from 'react-bootstrap'
import useSWR from 'swr'
import { fixedDecimal } from '../lib/format'
const fetcher = url => fetch(url).then(res => res.json()).catch()
@ -49,7 +50,6 @@ export default function Price () {
if (!price) return null
const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
const handleClick = () => {
if (asSats === 'yep') {
localStorage.setItem('asSats', '1btc')
@ -66,7 +66,7 @@ export default function Price () {
if (asSats === 'yep') {
return (
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
{fixed(100000000 / price, 0) + ' sats/$'}
{fixedDecimal(100000000 / price, 0) + ' sats/$'}
</Button>
)
}
@ -81,7 +81,7 @@ export default function Price () {
return (
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
{'$' + fixed(price, 0)}
{'$' + fixedDecimal(price, 0)}
</Button>
)
}

View File

@ -0,0 +1,35 @@
import { Nav, Navbar } from 'react-bootstrap'
import styles from './header.module.css'
import Link from 'next/link'
export default function RecentHeader ({ itemType }) {
return (
<Navbar className='pt-0'>
<Nav
className={`${styles.navbarNav} justify-content-around`}
activeKey={itemType}
>
<Nav.Item>
<Link href='/recent' passHref>
<Nav.Link
eventKey='posts'
className={styles.navLink}
>
posts
</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href='/recent/comments' passHref>
<Nav.Link
eventKey='comments'
className={styles.navLink}
>
comments
</Nav.Link>
</Link>
</Nav.Item>
</Nav>
</Navbar>
)
}

View File

@ -4,11 +4,11 @@ import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css'
import { COMMENTS } from '../fragments/comments'
import { useMe } from './me'
import ActionTooltip from './action-tooltip'
import TextareaAutosize from 'react-textarea-autosize'
import { useEffect, useState } from 'react'
import Info from './info'
import Link from 'next/link'
import FeeButton from './fee-button'
import { commentsViewedAfterComment } from '../lib/new-comments'
export const CommentSchema = Yup.object({
text: Yup.string().required('required').trim()
@ -22,9 +22,10 @@ export function ReplyOnAnotherPage ({ parentId }) {
)
}
export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) {
export default function Reply ({ item, onSuccess, replyOpen }) {
const [reply, setReply] = useState(replyOpen)
const me = useMe()
const parentId = item.id
useEffect(() => {
setReply(replyOpen || !!localStorage.getItem('reply-' + parentId + '-' + 'text'))
@ -52,21 +53,32 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) {
fragmentName: 'CommentsRecursive'
})
return [newCommentRef, ...existingCommentRefs]
},
ncomments (existingNComments = 0) {
return existingNComments + 1
},
meComments (existingMeComments = 0) {
return existingMeComments + 1
}
}
})
const ancestors = item.path.split('.')
// update all ancestors
ancestors.forEach(id => {
cache.modify({
id: `Item:${id}`,
fields: {
ncomments (existingNComments = 0) {
return existingNComments + 1
}
}
})
})
// so that we don't see indicator for our own comments, we record this comments as the latest time
// but we also have record num comments, in case someone else commented when we did
const root = ancestors[0]
commentsViewedAfterComment(root, createComment.createdAt)
}
}
)
const cost = me?.freeComments ? 0 : Math.pow(10, meComments)
return (
<div>
{replyOpen
@ -102,16 +114,13 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) {
required
hint={me?.freeComments ? <span className='text-success'>{me.freeComments} free comments left</span> : null}
/>
<div className='d-flex align-items-center mt-1'>
<ActionTooltip overlayText={`${cost} sats`}>
<SubmitButton variant='secondary'>reply{cost > 1 && <small> {cost} sats</small>}</SubmitButton>
</ActionTooltip>
{cost > 1 && (
<Info>
<div className='font-weight-bold'>Multiple replies on the same level get pricier, but we still love your thoughts!</div>
</Info>
)}
</div>
{reply &&
<div className='mt-1'>
<FeeButton
baseFee={1} parentId={parentId} text='reply'
ChildButton={SubmitButton} variant='secondary' alwaysShow
/>
</div>}
</Form>
</div>
</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'
@ -50,7 +50,8 @@ export default function Search ({ sub }) {
required
autoFocus={showSearch && !atBottom}
groupClassName='mr-3 mb-0 flex-grow-1'
className='w-100'
className='flex-grow-1'
clear
onChange={async (formik, e) => {
setSearching(true)
setQ(e.target.value?.trim())

View File

@ -0,0 +1,91 @@
import React, { useState } from 'react'
import { Dropdown, FormControl } from 'react-bootstrap'
import TocIcon from '../svgs/list-unordered.svg'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { visit } from 'unist-util-visit'
import { toString } from 'mdast-util-to-string'
import GithubSlugger from 'github-slugger'
export default function Toc ({ text }) {
if (!text || text.length === 0) {
return null
}
const tree = fromMarkdown(text)
const toc = []
const slugger = new GithubSlugger()
visit(tree, 'heading', (node, position, parent) => {
const str = toString(node)
toc.push({ heading: str, slug: slugger.slug(str.replace(/[^\w\-\s]+/gi, '')), depth: node.depth })
})
if (toc.length === 0) {
return null
}
return (
<Dropdown alignRight className='d-flex align-items-center'>
<Dropdown.Toggle as={CustomToggle} id='dropdown-custom-components'>
<TocIcon className='mx-2 fill-grey theme' />
</Dropdown.Toggle>
<Dropdown.Menu as={CustomMenu}>
{toc.map(v => {
return (
<Dropdown.Item
className={v.depth === 1 ? 'font-weight-bold' : ''}
style={{
marginLeft: `${(v.depth - 1) * 5}px`
}}
key={v.slug} href={`#${v.slug}`}
>{v.heading}
</Dropdown.Item>
)
})}
</Dropdown.Menu>
</Dropdown>
)
}
const CustomToggle = React.forwardRef(({ children, onClick }, ref) => (
<a
href=''
ref={ref}
onClick={(e) => {
e.preventDefault()
onClick(e)
}}
>
{children}
</a>
))
// forwardRef again here!
// Dropdown needs access to the DOM of the Menu to measure it
const CustomMenu = React.forwardRef(
({ children, style, className, 'aria-labelledby': labeledBy }, ref) => {
const [value, setValue] = useState('')
return (
<div
ref={ref}
style={style}
className={className}
aria-labelledby={labeledBy}
>
<FormControl
className='mx-3 my-2 w-auto'
placeholder='filter'
onChange={(e) => setValue(e.target.value)}
value={value}
/>
<ul className='list-unstyled'>
{React.Children.toArray(children).filter(
(child) =>
!value || child.props.children.toLowerCase().includes(value)
)}
</ul>
</div>
)
}
)

View File

@ -11,6 +11,7 @@ import reactStringReplace from 'react-string-replace'
import React, { useEffect, useState } from 'react'
import GithubSlugger from 'github-slugger'
import Link from '../svgs/link.svg'
import {toString} from 'mdast-util-to-string'
function copyToClipboard (id) {
if (navigator && navigator.clipboard && navigator.clipboard.writeText)
@ -35,17 +36,10 @@ function myRemarkPlugin () {
}
}
function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props }) {
const id = noFragments
? undefined
: slugger.slug(children.reduce(
(acc, cur) => {
if (typeof cur !== 'string') {
return acc
}
return acc + cur.replace(/[^\w\-\s]+/gi, '')
}, ''))
console.log(id)
const id = noFragments ? undefined : slugger.slug(toString(node).replace(/[^\w\-\s]+/gi, ''))
return (
<div className={styles.heading}>
@ -100,6 +94,11 @@ export default function Text ({ topLevel, noFragments, nofollow, children }) {
)
},
a: ({ node, href, children, ...props }) => {
if (children?.some(e => e?.props?.node?.tagName === 'img')) {
return <>{children}</>
}
// map: fix any highlighted links
children = children?.map(e =>
typeof e === 'string'
? reactStringReplace(e, /:high\[([^\]]+)\]/g, (match, i) => {

View File

@ -63,7 +63,7 @@
display: block;
margin-top: .5rem;
border-radius: .4rem;
width: min-content;
width: auto;
max-width: 100%;
}

View File

@ -135,6 +135,19 @@ export default function UpVote ({ item, className }) {
}
}
})
// update all ancestors
item.path.split('.').forEach(id => {
if (Number(id) === Number(item.id)) return
cache.modify({
id: `Item:${id}`,
fields: {
commentSats (existingCommentSats = 0) {
return existingCommentSats + sats
}
}
})
})
}
}
)

View File

@ -1,8 +1,8 @@
import { Button, InputGroup, Image, Modal, Form as BootstrapForm } from 'react-bootstrap'
import { Button, InputGroup, Image } from 'react-bootstrap'
import Link from 'next/link'
import { useRouter } from 'next/router'
import Nav from 'react-bootstrap/Nav'
import { useRef, useState } from 'react'
import { useState } from 'react'
import { Form, Input, SubmitButton } from './form'
import * as Yup from 'yup'
import { gql, useApolloClient, useMutation } from '@apollo/client'
@ -13,10 +13,7 @@ import QRCode from 'qrcode.react'
import LightningIcon from '../svgs/bolt.svg'
import ModalButton from './modal-button'
import { encodeLNUrl } from '../lib/lnurl'
import Upload from './upload'
import EditImage from '../svgs/image-edit-fill.svg'
import Moon from '../svgs/moon-fill.svg'
import AvatarEditor from 'react-avatar-editor'
import Avatar from './avatar'
export default function UserHeader ({ user }) {
const [editting, setEditting] = useState(false)
@ -25,6 +22,24 @@ export default function UserHeader ({ user }) {
const client = useApolloClient()
const [setName] = useMutation(NAME_MUTATION)
const [setPhoto] = useMutation(
gql`
mutation setPhoto($photoId: ID!) {
setPhoto(photoId: $photoId)
}`, {
update (cache, { data: { setPhoto } }) {
cache.modify({
id: `User:${user.id}`,
fields: {
photoId () {
return setPhoto
}
}
})
}
}
)
const isMe = me?.name === user.name
const Satistics = () => <div className={`mb-2 ml-0 ml-sm-1 ${styles.username} text-success`}>{isMe ? `${user.sats} sats \\ ` : ''}{user.stacked} stacked</div>
@ -54,7 +69,14 @@ export default function UserHeader ({ user }) {
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='135' height='135'
className={styles.userimg}
/>
{isMe && <PhotoEditor userId={me.id} />}
{isMe &&
<Avatar onSuccess={async photoId => {
const { error } = await setPhoto({ variables: { photoId } })
if (error) {
console.log(error)
}
}}
/>}
</div>
<div className='ml-0 ml-sm-1 mt-3 mt-sm-0 justify-content-center align-self-sm-center'>
{editting
@ -74,9 +96,11 @@ export default function UserHeader ({ user }) {
if (error) {
throw new Error({ message: error.toString() })
}
const { nodata, ...query } = router.query
router.replace({
pathname: router.pathname,
query: { ...router.query, name }
query: { ...query, name }
})
client.writeFragment({
@ -161,92 +185,3 @@ export default function UserHeader ({ user }) {
</>
)
}
function PhotoEditor ({ userId }) {
const [uploading, setUploading] = useState()
const [editProps, setEditProps] = useState()
const ref = useRef()
const [scale, setScale] = useState(1)
const [setPhoto] = useMutation(
gql`
mutation setPhoto($photoId: ID!) {
setPhoto(photoId: $photoId)
}`, {
update (cache, { data: { setPhoto } }) {
cache.modify({
id: `User:${userId}`,
fields: {
photoId () {
return setPhoto
}
}
})
}
}
)
return (
<>
<Modal
show={!!editProps}
onHide={() => setEditProps(null)}
>
<div className='modal-close' onClick={() => setEditProps(null)}>X</div>
<Modal.Body className='text-right mt-1 p-4'>
<AvatarEditor
ref={ref} width={200} height={200}
image={editProps?.file}
scale={scale}
style={{
width: '100%',
height: 'auto'
}}
/>
<BootstrapForm.Group controlId='formBasicRange'>
<BootstrapForm.Control
type='range' onChange={e => setScale(parseFloat(e.target.value))}
min={1} max={2} step='0.05'
defaultValue={scale} custom
/>
</BootstrapForm.Group>
<Button onClick={() => {
ref.current.getImageScaledToCanvas().toBlob(blob => {
if (blob) {
editProps.upload(blob)
setEditProps(null)
}
}, 'image/jpeg')
}}
>save
</Button>
</Modal.Body>
</Modal>
<Upload
as={({ onClick }) =>
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
{uploading
? <Moon className='fill-white spin' />
: <EditImage className='fill-white' />}
</div>}
onError={e => {
console.log(e)
setUploading(false)
}}
onSelect={(file, upload) => {
setEditProps({ file, upload })
}}
onSuccess={async key => {
const { error } = await setPhoto({ variables: { photoId: key } })
if (error) {
console.log(error)
}
setUploading(false)
}}
onStarted={() => {
setUploading(true)
}}
/>
</>
)
}

View File

@ -14,8 +14,11 @@ export const COMMENT_FIELDS = gql`
upvotes
boost
meSats
meComments
meDontLike
outlawed
freebie
path
commentSats
mine
ncomments
root {

View File

@ -21,8 +21,14 @@ export const ITEM_FIELDS = gql`
boost
path
meSats
meDontLike
outlawed
freebie
ncomments
commentSats
lastCommentAt
maxBid
isJob
company
location
remote
@ -30,7 +36,9 @@ export const ITEM_FIELDS = gql`
name
baseCost
}
pollCost
status
uploadId
mine
root {
id
@ -62,12 +70,67 @@ 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 {
meVoted
count
options {
id
option
count
meVoted
}
}
}`
export const ITEM = gql`
${ITEM_FIELDS}
${POLL_FIELDS}
query Item($id: ID!) {
item(id: $id) {
...ItemFields
...PollFields
text
}
}`
@ -84,14 +147,15 @@ export const COMMENTS_QUERY = gql`
export const ITEM_FULL = gql`
${ITEM_FIELDS}
${POLL_FIELDS}
${COMMENTS}
query Item($id: ID!) {
item(id: $id) {
...ItemFields
prior
meComments
position
text
...PollFields
comments {
...CommentsRecursive
}
@ -104,7 +168,6 @@ export const ITEM_WITH_COMMENTS = gql`
fragment ItemWithComments on Item {
...ItemFields
text
meComments
comments {
...CommentsRecursive
}

View File

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

View File

@ -29,6 +29,11 @@ export const SUB_ITEMS = gql`
cursor
items {
...ItemFields
position
},
pins {
...ItemFields
position
}
}
}

View File

@ -24,6 +24,9 @@ export const ME = gql`
noteDeposits
noteInvites
noteJobIndicator
hideInvoiceDesc
wildWestMode
greeterMode
lastCheckedJobs
}
}`
@ -48,13 +51,15 @@ export const ME_SSR = gql`
noteDeposits
noteInvites
noteJobIndicator
hideInvoiceDesc
wildWestMode
greeterMode
lastCheckedJobs
}
}`
export const SETTINGS = gql`
{
settings {
export const SETTINGS_FIELDS = gql`
fragment SettingsFields on User {
tipDefault
noteItemSats
noteEarning
@ -63,15 +68,42 @@ export const SETTINGS = gql`
noteDeposits
noteInvites
noteJobIndicator
hideInvoiceDesc
wildWestMode
greeterMode
authMethods {
lightning
email
twitter
github
}
}`
export const SETTINGS = gql`
${SETTINGS_FIELDS}
{
settings {
...SettingsFields
}
}`
export const SET_SETTINGS =
gql`
${SETTINGS_FIELDS}
mutation setSettings($tipDefault: Int!, $noteItemSats: Boolean!, $noteEarning: Boolean!,
$noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!,
$wildWestMode: Boolean!, $greeterMode: Boolean!) {
setSettings(tipDefault: $tipDefault, noteItemSats: $noteItemSats,
noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, wildWestMode: $wildWestMode,
greeterMode: $greeterMode) {
...SettingsFields
}
}
`
export const NAME_QUERY =
gql`
query nameAvailable($name: String!) {
@ -86,6 +118,14 @@ gql`
}
`
export const USER_SEARCH =
gql`
query searchUsers($name: String!) {
searchUsers(name: $name) {
name
}
}`
export const USER_FIELDS = gql`
${ITEM_FIELDS}
fragment UserFields on User {
@ -153,6 +193,11 @@ export const USER_WITH_POSTS = gql`
cursor
items {
...ItemFields
position
}
pins {
...ItemFields
position
}
}
}`

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) {
@ -79,7 +118,7 @@ export default function getApolloClient () {
}
},
notifications: {
keyArgs: false,
keyArgs: ['inc'],
merge (existing, incoming) {
if (isFirstPage(incoming.cursor, existing?.notifications)) {
return incoming

View File

@ -1,5 +1,5 @@
export const NOFOLLOW_LIMIT = 100
export const BOOST_MIN = 1000
export const NOFOLLOW_LIMIT = 1000
export const BOOST_MIN = 5000
export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024
export const IMAGE_PIXELS_MAX = 35000000
export const UPLOAD_TYPES_ALLOW = [
@ -11,3 +11,8 @@ export const UPLOAD_TYPES_ALLOW = [
]
export const COMMENT_DEPTH_LIMIT = 10
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

@ -5,3 +5,7 @@ export const formatSats = n => {
if (n >= 1e9 && n < 1e12) return +(n / 1e9).toFixed(1) + 'b'
if (n >= 1e12) return +(n / 1e12).toFixed(1) + 't'
}
export const fixedDecimal = (n, f) => {
return Number.parseFloat(n).toFixed(f)
}

19
lib/md.js Normal file
View File

@ -0,0 +1,19 @@
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { visit } from 'unist-util-visit'
import { gfm } from 'micromark-extension-gfm'
export function mdHas (md, test) {
const tree = fromMarkdown(md, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()]
})
let found = false
visit(tree, test, () => {
found = true
return false
})
return found
}

28
lib/new-comments.js Normal file
View File

@ -0,0 +1,28 @@
const COMMENTS_VIEW_PREFIX = 'commentsViewedAt'
const COMMENTS_NUM_PREFIX = 'commentsViewNum'
export function commentsViewed (item) {
if (!item.parentId && item.lastCommentAt) {
localStorage.setItem(`${COMMENTS_VIEW_PREFIX}:${item.id}`, new Date(item.lastCommentAt).getTime())
localStorage.setItem(`${COMMENTS_NUM_PREFIX}:${item.id}`, item.ncomments)
}
}
export function commentsViewedAfterComment (rootId, createdAt) {
localStorage.setItem(`${COMMENTS_VIEW_PREFIX}:${rootId}`, new Date(createdAt).getTime())
const existingRootComments = localStorage.getItem(`${COMMENTS_NUM_PREFIX}:${rootId}`) || 0
localStorage.setItem(`${COMMENTS_NUM_PREFIX}:${rootId}`, existingRootComments + 1)
}
export function newComments (item) {
if (!item.parentId) {
const commentsViewedAt = localStorage.getItem(`${COMMENTS_VIEW_PREFIX}:${item.id}`)
const commentsViewNum = localStorage.getItem(`${COMMENTS_NUM_PREFIX}:${item.id}`)
if (commentsViewedAt && commentsViewNum) {
return commentsViewedAt < new Date(item.lastCommentAt).getTime() || commentsViewNum < item.ncomments
}
}
return false
}

View File

@ -19,3 +19,25 @@ export function timeSince (timeStamp) {
return 'now'
}
export function timeLeft (timeStamp) {
const now = new Date()
const secondsPast = (timeStamp - now.getTime()) / 1000
if (secondsPast < 0) {
return false
}
if (secondsPast < 60) {
return parseInt(secondsPast) + 's'
}
if (secondsPast < 3600) {
return parseInt(secondsPast / 60) + 'm'
}
if (secondsPast <= 86400) {
return parseInt(secondsPast / 3600) + 'h'
}
if (secondsPast > 86400) {
return parseInt(secondsPast / (3600 * 24)) + ' days'
}
}

View File

@ -55,6 +55,10 @@ module.exports = withPlausibleProxy()({
source: '/story',
destination: '/items/1620'
},
{
source: '/privacy',
destination: '/items/76894'
},
{
source: '/.well-known/lnurlp/:username',
destination: '/api/lnurlp/:username'

View File

@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "NODE_OPTIONS='--trace-warnings' next dev",
"dev": "NODE_OPTIONS='--trace-warnings --inspect' next dev",
"build": "next build",
"migrate": "prisma migrate deploy",
"start": "NODE_OPTIONS='--trace-warnings' next start -p $PORT"
@ -28,6 +28,8 @@
"graphql-type-json": "^0.3.2",
"ln-service": "^52.8.0",
"mdast-util-find-and-replace": "^1.1.1",
"mdast-util-from-markdown": "^1.2.0",
"mdast-util-to-string": "^3.1.0",
"next": "^11.1.2",
"next-auth": "^3.29.3",
"next-plausible": "^2.1.3",

View File

@ -8,12 +8,12 @@ import { useState } from 'react'
import ItemFull from '../../components/item-full'
import * as Yup from 'yup'
import { Form, MarkdownInput, SubmitButton } from '../../components/form'
import ActionTooltip from '../../components/action-tooltip'
import TextareaAutosize from 'react-textarea-autosize'
import { useMe } from '../../components/me'
import { USER_FULL } from '../../fragments/users'
import { ITEM_FIELDS } from '../../fragments/items'
import { getGetServerSideProps } from '../../api/ssrApollo'
import FeeButton, { EditFeeButton } from '../../components/fee-button'
export const getServerSideProps = getGetServerSideProps(USER_FULL, null,
data => !data.user)
@ -69,9 +69,17 @@ export function BioForm ({ handleSuccess, bio }) {
as={TextareaAutosize}
minRows={6}
/>
<ActionTooltip>
<SubmitButton variant='secondary' className='mt-3'>{bio?.text ? 'save' : 'create'}</SubmitButton>
</ActionTooltip>
<div className='mt-3'>
{bio?.text
? <EditFeeButton
paidSats={bio?.meSats}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/>
: <FeeButton
baseFee={1} parentId={null} text='create'
ChildButton={SubmitButton} variant='secondary'
/>}
</div>
</Form>
</div>
)

View File

@ -10,7 +10,7 @@ export const getServerSideProps = getGetServerSideProps(USER_WITH_POSTS)
export default function UserPosts ({ data: { user, items: { items, cursor } } }) {
const { data } = useQuery(USER_WITH_POSTS,
{ variables: { name: user.name } })
{ variables: { name: user.name, sort: 'user' } })
if (data) {
({ user, items: { items, cursor } } = data)

View File

@ -1,5 +1,5 @@
import '../styles/globals.scss'
import { ApolloProvider, gql } from '@apollo/client'
import { ApolloProvider, gql, useQuery } from '@apollo/client'
import { Provider } from 'next-auth/client'
import { FundErrorModal, FundErrorProvider } from '../components/fund-error'
import { MeProvider } from '../components/me'
@ -10,27 +10,64 @@ import getApolloClient from '../lib/apollo'
import NextNProgress from 'nextjs-progressbar'
import { PriceProvider } from '../components/price'
import Head from 'next/head'
import { useRouter } from 'next/dist/client/router'
import { useEffect } from 'react'
import Moon from '../svgs/moon-fill.svg'
import Layout from '../components/layout'
function CSRWrapper ({ Component, apollo, ...props }) {
const { data, error } = useQuery(gql`${apollo.query}`, { variables: apollo.variables, fetchPolicy: 'cache-first' })
if (error) {
return (
<div className='d-flex font-weight-bold justify-content-center mt-3 mb-1'>
{error.toString()}
</div>
)
}
if (!data) {
return (
<Layout>
<div className='d-flex justify-content-center mt-3 mb-1'>
<Moon className='spin fill-grey' />
</div>
</Layout>
)
}
return <Component {...props} data={data} />
}
function MyApp ({ Component, pageProps: { session, ...props } }) {
const client = getApolloClient()
const router = useRouter()
useEffect(async () => {
// HACK: 'cause there's no way to tell Next to skip SSR
// So every page load, we modify the route in browser history
// to point to the same page but without SSR, ie ?nodata=true
// this nodata var will get passed to the server on back/foward and
// 1. prevent data from reloading and 2. perserve scroll
// (2) is not possible while intercepting nav with beforePopState
router.replace({
pathname: router.pathname,
query: { ...router.query, nodata: true }
}, router.asPath, { ...router.options, scroll: false })
}, [router.asPath])
/*
If we are on the client, we populate the apollo cache with the
ssr data
*/
if (typeof window !== 'undefined') {
const { apollo, data } = props
if (apollo) {
client.writeQuery({
query: gql`${apollo.query}`,
data: data,
variables: apollo.variables
})
}
const { apollo, data, me, price } = props
if (typeof window !== 'undefined' && apollo && data) {
client.writeQuery({
query: gql`${apollo.query}`,
data: data,
variables: apollo.variables
})
}
const { me, price } = props
return (
<>
<NextNProgress
@ -54,7 +91,9 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
<FundErrorModal />
<ItemActProvider>
<ItemActModal />
<Component {...props} />
{data || !apollo?.query
? <Component {...props} />
: <CSRWrapper Component={Component} {...props} />}
</ItemActProvider>
</FundErrorProvider>
</LightningProvider>

View File

@ -10,7 +10,7 @@ export default async ({ query: { username } }, res) => {
return res.status(200).json({
callback: `${process.env.PUBLIC_URL}/api/lnurlp/${username}/pay`, // The URL from LN SERVICE which will accept the pay request parameters
minSendable: 1000, // Min amount LN SERVICE is willing to receive, can not be less than 1 or more than `maxSendable`
maxSendable: Number.MAX_SAFE_INTEGER,
maxSendable: 1000000000,
metadata: lnurlPayMetadataString(username), // Metadata json which must be presented as raw string here, this is required to pass signature verification at a later step
tag: 'payRequest' // Type of LNURL
})

View File

@ -3,7 +3,6 @@ import lnd from '../../../../api/lnd'
import { createInvoice } from 'ln-service'
import { lnurlPayDescriptionHashForUser } from '../../../../lib/lnurl'
import serialize from '../../../../api/resolvers/serial'
import { belowInvoiceLimit } from '../../../../api/resolvers/wallet'
export default async ({ query: { username, amount } }, res) => {
const user = await models.user.findUnique({ where: { name: username } })
@ -15,17 +14,13 @@ export default async ({ query: { username, amount } }, res) => {
return res.status(400).json({ status: 'ERROR', reason: 'amount must be >=1000 msats' })
}
if (!await belowInvoiceLimit(models, user.id)) {
return res.status(400).json({ status: 'ERROR', reason: 'too many pending invoices' })
}
// generate invoice
const expiresAt = new Date(new Date().setMinutes(new Date().getMinutes() + 1))
const description = `${amount} msats for @${user.name} on stacker.news`
const descriptionHash = lnurlPayDescriptionHashForUser(username)
try {
const invoice = await createInvoice({
description,
description: user.hideInvoiceDesc ? undefined : description,
description_hash: descriptionHash,
lnd,
mtokens: amount,
@ -42,6 +37,6 @@ export default async ({ query: { username, amount } }, res) => {
})
} catch (error) {
console.log(error)
res.status(400).json({ status: 'ERROR', reason: 'failed to create invoice' })
res.status(400).json({ status: 'ERROR', reason: error.message })
}
}

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

@ -4,6 +4,7 @@ import { DiscussionForm } from '../../../components/discussion-form'
import { LinkForm } from '../../../components/link-form'
import LayoutCenter from '../../../components/layout-center'
import JobForm from '../../../components/job-form'
import { PollForm } from '../../../components/poll-form'
export const getServerSideProps = getGetServerSideProps(ITEM, null,
data => !data.item)
@ -13,11 +14,13 @@ 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} />
: <DiscussionForm item={item} editThreshold={editThreshold} />)}
? <LinkForm item={item} editThreshold={editThreshold} adv />
: (item.pollCost
? <PollForm item={item} editThreshold={editThreshold} />
: <DiscussionForm item={item} editThreshold={editThreshold} adv />))}
</LayoutCenter>
)
}

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

@ -6,6 +6,8 @@ import { useMe } from '../components/me'
import { DiscussionForm } from '../components/discussion-form'
import { LinkForm } from '../components/link-form'
import { getGetServerSideProps } from '../api/ssrApollo'
import AccordianItem from '../components/accordian-item'
import { PollForm } from '../components/poll-form'
export const getServerSideProps = getGetServerSideProps()
@ -16,6 +18,9 @@ export function PostForm () {
if (!router.query.type) {
return (
<div className='align-items-center'>
{me?.freePosts
? <div className='text-center font-weight-bold mb-3 text-success'>{me.freePosts} free posts left</div>
: null}
<Link href='/post?type=link'>
<Button variant='secondary'>link</Button>
</Link>
@ -23,17 +28,27 @@ export function PostForm () {
<Link href='/post?type=discussion'>
<Button variant='secondary'>discussion</Button>
</Link>
{me?.freePosts
? <div className='text-center font-weight-bold mt-3 text-success'>{me.freePosts} free posts left</div>
: null}
<div className='d-flex justify-content-center mt-3'>
<AccordianItem
headerColor='#6c757d'
header={<div className='font-weight-bold text-muted'>more</div>}
body={
<Link href='/post?type=poll'>
<Button variant='info'>poll</Button>
</Link>
}
/>
</div>
</div>
)
}
if (router.query.type === 'discussion') {
return <DiscussionForm adv />
} else {
} else if (router.query.type === 'link') {
return <LinkForm />
} else {
return <PollForm />
}
}

20
pages/recent/comments.js Normal file
View File

@ -0,0 +1,20 @@
import Layout from '../../components/layout'
import { getGetServerSideProps } from '../../api/ssrApollo'
import { MORE_FLAT_COMMENTS } from '../../fragments/comments'
import CommentsFlat from '../../components/comments-flat'
import RecentHeader from '../../components/recent-header'
const variables = { sort: 'recent' }
export const getServerSideProps = getGetServerSideProps(MORE_FLAT_COMMENTS, variables)
export default function Index ({ data: { moreFlatComments: { comments, cursor } } }) {
return (
<Layout>
<RecentHeader itemType='comments' />
<CommentsFlat
comments={comments} cursor={cursor}
variables={{ sort: 'recent' }} includeParent noReply
/>
</Layout>
)
}

View File

@ -1,7 +1,8 @@
import Layout from '../components/layout'
import Items from '../components/items'
import { getGetServerSideProps } from '../api/ssrApollo'
import { ITEMS } from '../fragments/items'
import Layout from '../../components/layout'
import Items from '../../components/items'
import { getGetServerSideProps } from '../../api/ssrApollo'
import { ITEMS } from '../../fragments/items'
import RecentHeader from '../../components/recent-header'
const variables = { sort: 'recent' }
export const getServerSideProps = getGetServerSideProps(ITEMS, variables)
@ -9,6 +10,7 @@ export const getServerSideProps = getGetServerSideProps(ITEMS, variables)
export default function Index ({ data: { items: { items, cursor } } }) {
return (
<Layout>
<RecentHeader itemType='posts' />
<Items
items={items} cursor={cursor}
variables={variables} rank

View File

@ -14,6 +14,7 @@ import { Checkbox, Form } from '../components/form'
import { useRouter } from 'next/router'
import Item from '../components/item'
import Comment from '../components/comment'
import React from 'react'
export const getServerSideProps = getGetServerSideProps(WALLET_HISTORY)
@ -142,6 +143,8 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
case 'withdrawal':
case 'invoice':
return `/${fact.type}s/${fact.factId}`
case 'earn':
return
default:
return `/items/${fact.factId}`
}
@ -200,17 +203,21 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
</tr>
</thead>
<tbody>
{facts.map((f, i) => (
<Link href={href(f)} key={f.id}>
<tr className={styles.row}>
<td className={`${styles.type} ${satusClass(f.status)}`}>{f.type}</td>
<td className={styles.description}>
<Detail fact={f} />
</td>
<td className={`${styles.sats} ${satusClass(f.status)}`}>{f.msats / 1000}</td>
</tr>
</Link>
))}
{facts.map((f, i) => {
const uri = href(f)
const Wrapper = uri ? Link : ({ href, ...props }) => <React.Fragment {...props} />
return (
<Wrapper href={uri} key={f.id}>
<tr className={styles.row}>
<td className={`${styles.type} ${satusClass(f.status)}`}>{f.type}</td>
<td className={styles.description}>
<Detail fact={f} />
</td>
<td className={`${styles.sats} ${satusClass(f.status)}`}>{Math.floor(f.msats / 1000)}</td>
</tr>
</Wrapper>
)
})}
</tbody>
</Table>
<MoreFooter cursor={cursor} fetchMore={fetchMore} Skeleton={SatisticsSkeleton} />

View File

@ -9,8 +9,9 @@ import LoginButton from '../components/login-button'
import { signIn } from 'next-auth/client'
import ModalButton from '../components/modal-button'
import { LightningAuth } from '../components/lightning-auth'
import { SETTINGS } from '../fragments/users'
import { SETTINGS, SET_SETTINGS } from '../fragments/users'
import { useRouter } from 'next/router'
import Info from '../components/info'
export const getServerSideProps = getGetServerSideProps(SETTINGS)
@ -27,16 +28,18 @@ export const WarningSchema = Yup.object({
export default function Settings ({ data: { settings } }) {
const [success, setSuccess] = useState()
const [setSettings] = useMutation(
gql`
mutation setSettings($tipDefault: Int!, $noteItemSats: Boolean!, $noteEarning: Boolean!,
$noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
$noteInvites: Boolean!, $noteJobIndicator: Boolean!) {
setSettings(tipDefault: $tipDefault, noteItemSats: $noteItemSats,
noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
noteJobIndicator: $noteJobIndicator)
}`
const [setSettings] = useMutation(SET_SETTINGS, {
update (cache, { data: { setSettings } }) {
cache.modify({
id: 'ROOT_QUERY',
fields: {
settings () {
return setSettings
}
}
})
}
}
)
const { data } = useQuery(SETTINGS)
@ -57,7 +60,10 @@ export default function Settings ({ data: { settings } }) {
noteMentions: settings?.noteMentions,
noteDeposits: settings?.noteDeposits,
noteInvites: settings?.noteInvites,
noteJobIndicator: settings?.noteJobIndicator
noteJobIndicator: settings?.noteJobIndicator,
hideInvoiceDesc: settings?.hideInvoiceDesc,
wildWestMode: settings?.wildWestMode,
greeterMode: settings?.greeterMode
}}
schema={SettingsSchema}
onSubmit={async ({ tipDefault, ...values }) => {
@ -108,6 +114,54 @@ export default function Settings ({ data: { settings } }) {
label='there is a new job'
name='noteJobIndicator'
/>
<div className='form-label'>privacy</div>
<Checkbox
label={
<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>
<li>It makes your invoice descriptions blank.</li>
<li>This only applies to invoices you create
<ul>
<li>lnurl-pay and lightning addresses still reference your nym</li>
</ul>
</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>
@ -241,6 +295,7 @@ function AuthMethods ({ methods }) {
placeholder={methods.email}
groupClassName='mb-0'
readOnly
noForm
/>
<Button
className='ml-2' variant='secondary' onClick={

View File

@ -97,6 +97,9 @@ const COLORS = [
]
function GrowthAreaChart ({ data, xName, title }) {
if (!data || data.length === 0) {
return null
}
return (
<ResponsiveContainer width='100%' height={300} minWidth={300}>
<AreaChart

View File

@ -153,7 +153,8 @@ export function WithdrawlForm () {
try {
const provider = await requestProvider()
const { paymentRequest: invoice } = await provider.makeInvoice({
defaultMemo: `Withdrawal for @${me.name} on SN`
defaultMemo: `Withdrawal for @${me.name} on SN`,
maximumAmount: Math.max(me.sats - MAX_FEE_DEFAULT, 0)
})
const { data } = await createWithdrawl({ variables: { invoice, maxFee: MAX_FEE_DEFAULT } })
router.push(`/withdrawals/${data.createWithdrawl.id}`)

View File

@ -81,13 +81,13 @@ function LoadWithdrawl () {
<div className='w-100'>
<CopyInput
label='invoice' type='text'
placeholder={data.withdrawl.bolt11} readOnly
placeholder={data.withdrawl.bolt11} readOnly noForm
/>
</div>
<div className='w-100'>
<Input
label='max fee' type='text'
placeholder={data.withdrawl.satsFeePaying} readOnly
placeholder={data.withdrawl.satsFeePaying} readOnly noForm
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
</div>

View File

@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[itemId]` on the table `Upload` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "uploadId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "Upload.itemId_unique" ON "Upload"("itemId");

View File

@ -0,0 +1,55 @@
-- AlterEnum
ALTER TYPE "ItemActType" ADD VALUE 'POLL';
-- AlterEnum
ALTER TYPE "PostType" ADD VALUE 'POLL';
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "pollCost" INTEGER;
-- CreateTable
CREATE TABLE "PollOption" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"itemId" INTEGER NOT NULL,
"option" TEXT NOT NULL,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PollVote" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" INTEGER NOT NULL,
"itemId" INTEGER NOT NULL,
"pollOptionId" INTEGER NOT NULL,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PollOption.itemId_index" ON "PollOption"("itemId");
-- CreateIndex
CREATE UNIQUE INDEX "PollVote.itemId_userId_unique" ON "PollVote"("itemId", "userId");
-- CreateIndex
CREATE INDEX "PollVote.userId_index" ON "PollVote"("userId");
-- CreateIndex
CREATE INDEX "PollVote.pollOptionId_index" ON "PollVote"("pollOptionId");
-- AddForeignKey
ALTER TABLE "PollOption" ADD FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PollVote" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PollVote" ADD FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PollVote" ADD FOREIGN KEY ("pollOptionId") REFERENCES "PollOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,23 @@
-- create poll
-- charges us to create poll
-- adds poll options to poll
CREATE OR REPLACE FUNCTION create_poll(title TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER, options TEXT[])
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := create_item(title, null, null, boost, null, user_id);
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;
$$;

View File

@ -0,0 +1,112 @@
CREATE OR REPLACE FUNCTION create_poll(title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER, options TEXT[])
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := create_item(title, null, text, boost, null, user_id);
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;
$$;
-- create poll vote
-- if user hasn't already voted
-- charges user item.pollCost
-- adds POLL to ItemAct
-- adds PollVote
CREATE OR REPLACE FUNCTION poll_vote(option_id INTEGER, user_id INTEGER)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option "PollOption";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT * INTO option FROM "PollOption" where id = option_id;
IF option IS NULL THEN
RAISE EXCEPTION 'INVALID_POLL_OPTION';
END IF;
SELECT * INTO item FROM "Item" where id = option."itemId";
IF item IS NULL THEN
RAISE EXCEPTION 'POLL_DOES_NOT_EXIST';
END IF;
IF item."userId" = user_id THEN
RAISE EXCEPTION 'POLL_OWNER_CANT_VOTE';
END IF;
IF EXISTS (SELECT 1 FROM "PollVote" WHERE "itemId" = item.id AND "userId" = user_id) THEN
RAISE EXCEPTION 'POLL_VOTE_ALREADY_EXISTS';
END IF;
PERFORM item_act(item.id, user_id, 'POLL', item."pollCost");
INSERT INTO "PollVote" (created_at, updated_at, "itemId", "pollOptionId", "userId")
VALUES (now_utc(), now_utc(), item.id, option_id, user_id);
RETURN item;
END;
$$;
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 = 'BOOST' OR act = 'POLL' THEN
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_sats, item_id, user_id, act, now_utc(), now_utc());
ELSE
-- 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;
END IF;
RETURN 0;
END;
$$;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "freeComments" SET DEFAULT 0;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "paidImgLink" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "freePosts" SET DEFAULT 0;

View File

@ -0,0 +1,83 @@
CREATE OR REPLACE FUNCTION item_spam(parent_id INTEGER, user_id INTEGER, within INTERVAL)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
repeats INTEGER;
self_replies INTEGER;
BEGIN
SELECT count(*) INTO repeats
FROM "Item"
WHERE (parent_id IS NULL AND "parentId" IS NULL OR "parentId" = parent_id)
AND "userId" = user_id
AND created_at > now_utc() - within;
IF parent_id IS NULL THEN
RETURN repeats;
END IF;
WITH RECURSIVE base AS (
SELECT "Item".id, "Item"."parentId", "Item"."userId"
FROM "Item"
WHERE id = parent_id AND "userId" = user_id AND created_at > now_utc() - within
UNION ALL
SELECT "Item".id, "Item"."parentId", "Item"."userId"
FROM base p
JOIN "Item" ON "Item".id = p."parentId" AND "Item"."userId" = p."userId" AND "Item".created_at > now_utc() - within)
SELECT count(*) INTO self_replies FROM base;
RETURN repeats + self_replies;
END;
$$;
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";
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;
INSERT INTO "Item" (title, url, text, "userId", "parentId", "fwdUserId", "paidImgLink", created_at, updated_at)
VALUES (title, url, text, user_id, parent_id, fwd_user_id, has_img_link, 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,107 @@
CREATE OR REPLACE FUNCTION update_item(item_id INTEGER,
item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER,
fwd_user_id INTEGER, has_img_link BOOLEAN)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
prior_cost INTEGER;
prior_act_id INTEGER;
cost INTEGER;
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT * INTO item FROM "Item" WHERE id = item_id;
-- if has_img_link we need to figure out new costs, which is their prior_cost * 9
IF has_img_link AND NOT item."paidImgLink" THEN
SELECT sats * 1000, id INTO prior_cost, prior_act_id
FROM "ItemAct"
WHERE act = 'VOTE' AND "itemId" = item.id AND "userId" = item."userId";
cost := prior_cost * 9;
IF cost > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
UPDATE users SET msats = msats - cost WHERE id = item."userId";
UPDATE "ItemAct" SET sats = (prior_cost + cost) / 1000 WHERE id = prior_act_id;
END IF;
UPDATE "Item" set title = item_title, url = item_url, text = item_text, "fwdUserId" = fwd_user_id, "paidImgLink" = has_img_link
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;
$$;
CREATE OR REPLACE FUNCTION 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)
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, has_img_link, 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;
$$;
CREATE OR REPLACE FUNCTION update_poll(
id INTEGER, title TEXT, text TEXT, boost INTEGER,
options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN)
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, has_img_link);
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;
$$;
CREATE OR REPLACE FUNCTION create_bio(title TEXT, text TEXT, user_id INTEGER, has_img_link BOOLEAN)
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, has_img_link, '0');
UPDATE users SET "bioId" = item.id WHERE id = user_id;
RETURN item;
END;
$$;

View File

@ -0,0 +1,43 @@
-- make sure integers/floats are positive (or null if optional)
-- users
ALTER TABLE users ADD CONSTRAINT "msats_positive" CHECK ("msats" >= 0) NOT VALID;
ALTER TABLE users ADD CONSTRAINT "stackedMsats_positive" CHECK ("stackedMsats" >= 0) NOT VALID;
ALTER TABLE users ADD CONSTRAINT "freeComments_positive" CHECK ("freeComments" >= 0) NOT VALID;
ALTER TABLE users ADD CONSTRAINT "freePosts_positive" CHECK ("freePosts" >= 0) NOT VALID;
ALTER TABLE users ADD CONSTRAINT "tipDefault_positive" CHECK ("tipDefault" >= 0) NOT VALID;
-- upload
ALTER TABLE "Upload" ADD CONSTRAINT "size_positive" CHECK ("size" >= 0) NOT VALID;
ALTER TABLE "Upload" ADD CONSTRAINT "width_positive" CHECK ("width" IS NULL OR "width" >= 0) NOT VALID;
ALTER TABLE "Upload" ADD CONSTRAINT "height_positive" CHECK ("height" IS NULL OR "height" >= 0) NOT VALID;
-- earn
ALTER TABLE "Earn" ADD CONSTRAINT "msats_positive" CHECK ("msats" >= 0) NOT VALID;
-- invite
ALTER TABLE "Invite" ADD CONSTRAINT "gift_positive" CHECK ("gift" IS NULL OR "gift" >= 0) NOT VALID;
ALTER TABLE "Invite" ADD CONSTRAINT "limit_positive" CHECK ("limit" IS NULL OR "limit" >= 0) NOT VALID;
-- item
ALTER TABLE "Item" ADD CONSTRAINT "boost_positive" CHECK ("boost" >= 0) NOT VALID;
ALTER TABLE "Item" ADD CONSTRAINT "minSalary_positive" CHECK ("minSalary" IS NULL OR "minSalary" >= 0) NOT VALID;
ALTER TABLE "Item" ADD CONSTRAINT "maxSalary_positive" CHECK ("maxSalary" IS NULL OR "maxSalary" >= 0) NOT VALID;
ALTER TABLE "Item" ADD CONSTRAINT "maxBid_positive" CHECK ("maxBid" IS NULL OR "maxBid" >= 0) NOT VALID;
ALTER TABLE "Item" ADD CONSTRAINT "pollCost_positive" CHECK ("pollCost" IS NULL OR "pollCost" >= 0) NOT VALID;
-- sub
ALTER TABLE "Sub" ADD CONSTRAINT "baseCost_positive" CHECK ("baseCost" >= 0) NOT VALID;
-- item_act
ALTER TABLE "ItemAct" ADD CONSTRAINT "sats_positive" CHECK ("sats" >= 0) NOT VALID;
-- invoice
ALTER TABLE "Invoice" ADD CONSTRAINT "msatsRequested_positive" CHECK ("msatsRequested" >= 0) NOT VALID;
ALTER TABLE "Invoice" ADD CONSTRAINT "msatsReceived_positive" CHECK ("msatsReceived" IS NULL OR "msatsReceived" >= 0) NOT VALID;
-- withdrawl
ALTER TABLE "Withdrawl" ADD CONSTRAINT "msatsPaying_positive" CHECK ("msatsPaying" >= 0) NOT VALID;
ALTER TABLE "Withdrawl" ADD CONSTRAINT "msatsPaid_positive" CHECK ("msatsPaid" IS NULL OR "msatsPaid" >= 0) NOT VALID;
ALTER TABLE "Withdrawl" ADD CONSTRAINT "msatsFeePaying_positive" CHECK ("msatsFeePaying" >= 0) NOT VALID;
ALTER TABLE "Withdrawl" ADD CONSTRAINT "msatsFeePaid_positive" CHECK ("msatsFeePaid" IS NULL OR "msatsFeePaid" >= 0) NOT VALID;

View File

@ -0,0 +1,35 @@
CREATE OR REPLACE FUNCTION create_invoice(hash TEXT, bolt11 TEXT, expires_at timestamp(3) without time zone, msats_req INTEGER, user_id INTEGER)
RETURNS "Invoice"
LANGUAGE plpgsql
AS $$
DECLARE
invoice "Invoice";
limit_reached BOOLEAN;
too_much BOOLEAN;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT count(*) >= 10, sum("msatsRequested")+max(users.msats)+msats_req > 1000000000 INTO limit_reached, too_much
FROM "Invoice"
JOIN users on "userId" = users.id
WHERE "userId" = user_id AND "expiresAt" > now_utc() AND "confirmedAt" is null AND cancelled = false;
-- prevent more than 10 pending invoices
IF limit_reached THEN
RAISE EXCEPTION 'SN_INV_PENDING_LIMIT';
END IF;
-- prevent pending invoices + msats from exceeding 1,000,000 sats
IF too_much THEN
RAISE EXCEPTION 'SN_INV_EXCEED_BALANCE';
END IF;
INSERT INTO "Invoice" (hash, bolt11, "expiresAt", "msatsRequested", "userId", created_at, updated_at)
VALUES (hash, bolt11, expires_at, msats_req, user_id, now_utc(), now_utc()) RETURNING * INTO invoice;
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('checkInvoice', jsonb_build_object('hash', hash), 21, true, now() + interval '10 seconds');
RETURN invoice;
END;
$$;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "hideInvoiceDesc" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1 @@
CREATE EXTENSION pg_trgm;

View File

@ -0,0 +1,86 @@
-- AlterTable
ALTER TABLE "Item"
ADD COLUMN IF NOT EXISTS "commentSats" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "lastCommentAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "ncomments" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "sats" INTEGER NOT NULL DEFAULT 0;
-- denormalize all existing comments
-- for every item, compute the lastest comment time and number of comments
UPDATE "Item"
SET "lastCommentAt" = subquery."lastCommentAt", ncomments = subquery.ncomments
FROM (
SELECT a.id, MAX(b.created_at) AS "lastCommentAt", COUNT(b.id) AS ncomments
FROM "Item" a
LEFT JOIN "Item" b ON b.path <@ a.path AND a.id <> b.id
GROUP BY a.id) subquery
WHERE "Item".id = subquery.id;
-- on comment denormalize comment count and time
CREATE OR REPLACE FUNCTION ncomments_after_comment() RETURNS TRIGGER AS $$
DECLARE
BEGIN
UPDATE "Item"
SET "lastCommentAt" = now_utc(), "ncomments" = "ncomments" + 1
WHERE id <> NEW.id and path @> NEW.path;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ncomments_after_comment_trigger ON "Item";
CREATE TRIGGER ncomments_after_comment_trigger
AFTER INSERT ON "Item"
FOR EACH ROW
EXECUTE PROCEDURE ncomments_after_comment();
-- denormalize all existing sats
-- for every item, compute the total sats it has earned
UPDATE "Item"
SET sats = subquery.sats
FROM (
SELECT a.id, SUM(c.sats) AS sats
FROM "Item" a
JOIN "ItemAct" c ON c."itemId" = a.id AND c."userId" <> a."userId"
WHERE c.act IN ('VOTE', 'TIP')
GROUP BY a.id) subquery
WHERE "Item".id = subquery.id;
-- denormalize comment sats
UPDATE "Item"
SET "commentSats" = subquery."commentSats"
FROM (
SELECT a.id, SUM(b.sats) AS "commentSats"
FROM "Item" a
JOIN "Item" b ON b.path <@ a.path AND a.id <> b.id
GROUP BY a.id) subquery
WHERE "Item".id = subquery.id;
-- on item act denormalize sats and comment sats
CREATE OR REPLACE FUNCTION sats_after_act() RETURNS TRIGGER AS $$
DECLARE
item "Item";
BEGIN
SELECT * FROM "Item" WHERE id = NEW."itemId" INTO item;
IF item."userId" = NEW."userId" THEN
RETURN NEW;
END IF;
UPDATE "Item"
SET "sats" = "sats" + NEW.sats
WHERE id = item.id;
UPDATE "Item"
SET "commentSats" = "commentSats" + NEW.sats
WHERE id <> item.id and path @> item.path;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS sats_after_act_trigger ON "ItemAct";
CREATE TRIGGER sats_after_act_trigger
AFTER INSERT ON "ItemAct"
FOR EACH ROW
WHEN (NEW.act = 'VOTE' or NEW.act = 'TIP')
EXECUTE PROCEDURE sats_after_act();

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;

Some files were not shown because too many files have changed in this diff Show More