Merge branch 'master' into master
This commit is contained in:
commit
1c45f651eb
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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`
|
||||
}
|
||||
|
@ -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 })
|
||||
|
@ -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')
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
return latest.createdAt
|
||||
return latest?.createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
`
|
||||
|
@ -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 {
|
||||
|
@ -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!
|
||||
}
|
||||
|
@ -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'}
|
||||
|
@ -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
74
components/avatar.js
Normal 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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
|
@ -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`}>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 => (
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
54
components/dont-link-this.js
Normal file
54
components/dont-link-this.js
Normal 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
119
components/fee-button.js
Normal 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>
|
||||
)
|
||||
}
|
15
components/fee-button.module.css
Normal file
15
components/fee-button.module.css
Normal 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);
|
||||
}
|
@ -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>}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
|
@ -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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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
91
components/item-job.js
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -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}>
|
||||
|
@ -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
47
components/items-mixed.js
Normal 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} />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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 (
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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} />
|
||||
</>
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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
107
components/poll-form.js
Normal 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
99
components/poll.js
Normal 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>
|
||||
)
|
||||
}
|
45
components/poll.module.css
Normal file
45
components/poll.module.css
Normal 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;
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
35
components/recent-header.js
Normal file
35
components/recent-header.js
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
@ -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())
|
||||
|
91
components/table-of-contents.js
Normal file
91
components/table-of-contents.js
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
@ -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) => {
|
||||
|
@ -63,7 +63,7 @@
|
||||
display: block;
|
||||
margin-top: .5rem;
|
||||
border-radius: .4rem;
|
||||
width: min-content;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -14,8 +14,11 @@ export const COMMENT_FIELDS = gql`
|
||||
upvotes
|
||||
boost
|
||||
meSats
|
||||
meComments
|
||||
meDontLike
|
||||
outlawed
|
||||
freebie
|
||||
path
|
||||
commentSats
|
||||
mine
|
||||
ncomments
|
||||
root {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -31,6 +31,11 @@ export const NOTIFICATIONS = gql`
|
||||
... on Earn {
|
||||
sortTime
|
||||
earnedSats
|
||||
sources {
|
||||
posts
|
||||
comments
|
||||
tips
|
||||
}
|
||||
}
|
||||
... on Reply {
|
||||
sortTime
|
||||
|
@ -29,6 +29,11 @@ export const SUB_ITEMS = gql`
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
position
|
||||
},
|
||||
pins {
|
||||
...ItemFields
|
||||
position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
19
lib/md.js
Normal 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
28
lib/new-comments.js
Normal 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
|
||||
}
|
22
lib/time.js
22
lib/time.js
@ -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'
|
||||
}
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
})
|
||||
|
@ -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
32
pages/borderland.js
Normal 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
32
pages/freebie.js
Normal 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} />
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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
32
pages/outlawed.js
Normal 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} />
|
||||
}
|
@ -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
20
pages/recent/comments.js
Normal 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>
|
||||
)
|
||||
}
|
@ -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
|
@ -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} />
|
||||
|
@ -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={
|
||||
|
@ -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
|
||||
|
@ -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}`)
|
||||
|
@ -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>
|
||||
|
11
prisma/migrations/20220720211644_item_uploads/migration.sql
Normal file
11
prisma/migrations/20220720211644_item_uploads/migration.sql
Normal 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");
|
55
prisma/migrations/20220727194641_polls/migration.sql
Normal file
55
prisma/migrations/20220727194641_polls/migration.sql
Normal 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;
|
@ -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;
|
||||
$$;
|
112
prisma/migrations/20220727203003_poll_functions2/migration.sql
Normal file
112
prisma/migrations/20220727203003_poll_functions2/migration.sql
Normal 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;
|
||||
$$;
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ALTER COLUMN "freeComments" SET DEFAULT 0;
|
5
prisma/migrations/20220810162813_item_spam/migration.sql
Normal file
5
prisma/migrations/20220810162813_item_spam/migration.sql
Normal 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;
|
83
prisma/migrations/20220810203210_item_spam2/migration.sql
Normal file
83
prisma/migrations/20220810203210_item_spam2/migration.sql
Normal 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;
|
||||
$$;
|
107
prisma/migrations/20220815195309_edit_funcs/migration.sql
Normal file
107
prisma/migrations/20220815195309_edit_funcs/migration.sql
Normal 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;
|
||||
$$;
|
43
prisma/migrations/20220827143828_positive_ints/migration.sql
Normal file
43
prisma/migrations/20220827143828_positive_ints/migration.sql
Normal 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;
|
@ -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;
|
||||
$$;
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "hideInvoiceDesc" BOOLEAN NOT NULL DEFAULT false;
|
@ -0,0 +1 @@
|
||||
CREATE EXTENSION pg_trgm;
|
@ -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();
|
10
prisma/migrations/20220913173806_earn_columns/migration.sql
Normal file
10
prisma/migrations/20220913173806_earn_columns/migration.sql
Normal 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");
|
16
prisma/migrations/20220913173826_earn_function/migration.sql
Normal file
16
prisma/migrations/20220913173826_earn_function/migration.sql
Normal 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;
|
8
prisma/migrations/20220920152500_downvotes/migration.sql
Normal file
8
prisma/migrations/20220920152500_downvotes/migration.sql
Normal 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
Loading…
x
Reference in New Issue
Block a user