Merge branch 'master' into master

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

View File

@ -13,7 +13,7 @@ files:
content: | content: |
HTTPTunnelPort 127.0.0.1:7050 HTTPTunnelPort 127.0.0.1:7050
SocksPort 0 SocksPort 0
Log notice syslog Log info file /var/log/tor/info.log
HiddenServiceDir /var/lib/tor/sn/ HiddenServiceDir /var/lib/tor/sn/
HiddenServicePort 80 127.0.0.1:443 HiddenServicePort 80 127.0.0.1:443
services: services:

View File

@ -33,10 +33,10 @@ export default {
return await models.$queryRaw( return await models.$queryRaw(
`SELECT date_trunc('month', "ItemAct".created_at) AS time, `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 = 'STREAM' THEN "ItemAct".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 IN ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN "ItemAct".sats ELSE 0 END) as fees,
sum(CASE WHEN act = 'BOOST' THEN sats ELSE 0 END) as boost, sum(CASE WHEN act = 'BOOST' THEN "ItemAct".sats ELSE 0 END) as boost,
sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END) as tips sum(CASE WHEN act = 'TIP' THEN "ItemAct".sats ELSE 0 END) as tips
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE date_trunc('month', now_utc()) <> date_trunc('month', "ItemAct".created_at) 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 `SELECT time, sum(airdrop) as rewards, sum(post) as posts, sum(comment) as comments
FROM FROM
((SELECT date_trunc('month', "ItemAct".created_at) AS time, 0 as airdrop, ((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 0 ELSE "ItemAct".sats END as comment,
CASE WHEN "Item"."parentId" IS NULL THEN sats ELSE 0 END as post CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".sats ELSE 0 END as post
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId" 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 WHERE date_trunc('month', now_utc()) <> date_trunc('month', "ItemAct".created_at) AND
@ -121,10 +121,10 @@ export default {
spentWeekly: async (parent, args, { models }) => { spentWeekly: async (parent, args, { models }) => {
const [stats] = await models.$queryRaw( const [stats] = await models.$queryRaw(
`SELECT json_build_array( `SELECT json_build_array(
json_build_object('name', 'jobs', 'value', sum(CASE WHEN act = 'STREAM' THEN sats ELSE 0 END)), 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 = 'VOTE' AND "Item"."userId" = "ItemAct"."userId" THEN 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 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 sats ELSE 0 END))) as array json_build_object('name', 'tips', 'value', sum(CASE WHEN act = 'TIP' THEN "ItemAct".sats ELSE 0 END))) as array
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE "ItemAct".created_at >= now_utc() - interval '1 week'`) WHERE "ItemAct".created_at >= now_utc() - interval '1 week'`)
@ -140,8 +140,8 @@ export default {
) as array ) as array
FROM FROM
((SELECT 0 as airdrop, ((SELECT 0 as airdrop,
CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE sats END as comment, CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".sats END as comment,
CASE WHEN "Item"."parentId" IS NULL THEN sats ELSE 0 END as post CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".sats ELSE 0 END as post
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId" JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId"
WHERE "ItemAct".created_at >= now_utc() - interval '1 week' AND WHERE "ItemAct".created_at >= now_utc() - interval '1 week' AND

View File

@ -4,19 +4,22 @@ import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import domino from 'domino' import domino from 'domino'
import { BOOST_MIN } 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 let orderBy
switch (sort) { switch (sort) {
case 'top': case 'top':
orderBy = 'ORDER BY "Item"."weightedVotes" DESC, "Item".id DESC' orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".id DESC`
break break
case 'recent': case 'recent':
orderBy = 'ORDER BY "Item".created_at DESC, "Item".id DESC' orderBy = 'ORDER BY "Item".created_at DESC, "Item".id DESC'
break break
default: default:
orderBy = COMMENTS_ORDER_BY_SATS orderBy = `ORDER BY ${await orderByNumerator(me, models)}/POWER(EXTRACT(EPOCH FROM ((NOW() AT TIME ZONE 'UTC') - "Item".created_at))/3600+2, 1.3) DESC NULLS LAST, "Item".id DESC`
break break
} }
@ -25,18 +28,18 @@ async function comments (models, id, sort) {
${SELECT}, ARRAY[row_number() OVER (${orderBy}, "Item".path)] AS sort_path ${SELECT}, ARRAY[row_number() OVER (${orderBy}, "Item".path)] AS sort_path
FROM "Item" FROM "Item"
WHERE "parentId" = $1 WHERE "parentId" = $1
${await filterClause(me, models)}
UNION ALL UNION ALL
${SELECT}, p.sort_path || row_number() OVER (${orderBy}, "Item".path) ${SELECT}, p.sort_path || row_number() OVER (${orderBy}, "Item".path)
FROM base p FROM base p
JOIN "Item" ON "Item"."parentId" = p.id) JOIN "Item" ON "Item"."parentId" = p.id
WHERE true
${await filterClause(me, models)})
SELECT * FROM base ORDER BY sort_path`, Number(id)) SELECT * FROM base ORDER BY sort_path`, Number(id))
return nestComments(flat, id)[0] return nestComments(flat, id)[0]
} }
const COMMENTS_ORDER_BY_SATS = export async function getItem (parent, { id }, { me, models }) {
'ORDER BY POWER("Item"."weightedVotes", 1.2)/POWER(EXTRACT(EPOCH FROM ((NOW() AT TIME ZONE \'UTC\') - "Item".created_at))/3600+2, 1.3) DESC NULLS LAST, "Item".id DESC'
export async function getItem (parent, { id }, { models }) {
const [item] = await models.$queryRaw(` const [item] = await models.$queryRaw(`
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
@ -66,8 +69,61 @@ function topClause (within) {
return interval return interval
} }
export async function orderByNumerator (me, models) {
if (me) {
const user = await models.user.findUnique({ where: { id: me.id } })
if (user.wildWestMode) {
return 'GREATEST("Item"."weightedVotes", POWER("Item"."weightedVotes", 1.2))'
}
}
return `(CASE WHEN "Item"."weightedVotes" > "Item"."weightedDownVotes"
THEN 1
ELSE -1 END
* GREATEST(ABS("Item"."weightedVotes" - "Item"."weightedDownVotes"), POWER(ABS("Item"."weightedVotes" - "Item"."weightedDownVotes"), 1.2)))`
}
export async function filterClause (me, models) {
// by default don't include freebies unless they have upvotes
let clause = ' AND (NOT "Item".freebie OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0'
if (me) {
const user = await models.user.findUnique({ where: { id: me.id } })
// wild west mode has everything
if (user.wildWestMode) {
return ''
}
// greeter mode includes freebies if feebies haven't been flagged
if (user.greeterMode) {
clause = 'AND (NOT "Item".freebie OR ("Item"."weightedVotes" - "Item"."weightedDownVotes" >= 0 AND "Item".freebie)'
}
// always include if it's mine
clause += ` OR "Item"."userId" = ${me.id})`
} else {
// close default freebie clause
clause += ')'
}
// if the item is above the threshold or is mine
clause += ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
if (me) {
clause += ` OR "Item"."userId" = ${me.id}`
}
clause += ')'
return clause
}
export default { export default {
Query: { Query: {
itemRepetition: async (parent, { parentId }, { me, models }) => {
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 }) => { items: async (parent, { sub, sort, cursor, name, within }, { me, models }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
let items; let user; let pins; let subFull 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 WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2
AND "pinId" IS NULL AND "pinId" IS NULL
${activeOrMine()} ${activeOrMine()}
${await filterClause(me, models)}
ORDER BY created_at DESC ORDER BY created_at DESC
OFFSET $3 OFFSET $3
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset) LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
@ -108,6 +165,7 @@ export default {
WHERE "parentId" IS NULL AND created_at <= $1 WHERE "parentId" IS NULL AND created_at <= $1
${subClause(3)} ${subClause(3)}
${activeOrMine()} ${activeOrMine()}
${await filterClause(me, models)}
ORDER BY created_at DESC ORDER BY created_at DESC
OFFSET $2 OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL') LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
@ -119,7 +177,8 @@ export default {
WHERE "parentId" IS NULL AND "Item".created_at <= $1 WHERE "parentId" IS NULL AND "Item".created_at <= $1
AND "pinId" IS NULL AND "pinId" IS NULL
${topClause(within)} ${topClause(within)}
${TOP_ORDER_BY_SATS} ${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
OFFSET $2 OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break break
@ -135,13 +194,24 @@ export default {
// we pull from their wallet // we pull from their wallet
// TODO: need to filter out by payment status // TODO: need to filter out by payment status
items = await models.$queryRaw(` items = await models.$queryRaw(`
${SELECT} SELECT *
FROM (
(${SELECT}
FROM "Item" FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1 WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL AND "pinId" IS NULL
${subClause(3)} ${subClause(3)}
AND status <> 'STOPPED' AND status = 'ACTIVE' AND "maxBid" > 0
ORDER BY (CASE WHEN status = 'ACTIVE' THEN "maxBid" ELSE 0 END) DESC, created_at ASC 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 OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub) LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub)
break break
@ -157,9 +227,10 @@ export default {
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1 AND "Item".created_at > $3 WHERE "parentId" IS NULL AND "Item".created_at <= $1 AND "Item".created_at > $3
AND "pinId" IS NULL AND "pinId" IS NULL AND NOT bio
${subClause(4)} ${subClause(4)}
${newTimedOrderByWeightedSats(1)} ${await filterClause(me, models)}
${await newTimedOrderByWeightedSats(me, models, 1)}
OFFSET $2 OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date().setDate(new Date().getDate() - 5)), sub || 'NULL') LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date().setDate(new Date().getDate() - 5)), sub || 'NULL')
} }
@ -169,9 +240,10 @@ export default {
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1 WHERE "parentId" IS NULL AND "Item".created_at <= $1
AND "pinId" IS NULL AND "pinId" IS NULL AND NOT bio
${subClause(3)} ${subClause(3)}
${newTimedOrderByWeightedSats(1)} ${await filterClause(me, models)}
${await newTimedOrderByWeightedSats(me, models, 1)}
OFFSET $2 OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL') LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
} }
@ -199,11 +271,66 @@ export default {
pins pins
} }
}, },
allItems: async (parent, { cursor }, { models }) => { allItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
const items = await models.$queryRaw(` const items = await models.$queryRaw(`
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
${await filterClause(me, models)}
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
outlawedItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const notMine = () => {
return me ? ` AND "userId" <> ${me.id} ` : ''
}
const items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD}
${notMine()}
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
borderlandItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const notMine = () => {
return me ? ` AND "userId" <> ${me.id} ` : ''
}
const items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" < 0
AND "Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}
${notMine()}
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
freebieItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "Item".freebie
ORDER BY created_at DESC ORDER BY created_at DESC
OFFSET $1 OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset) LIMIT ${LIMIT}`, decodedCursor.offset)
@ -217,6 +344,16 @@ export default {
let comments, user let comments, user
switch (sort) { 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': case 'user':
if (!name) { if (!name) {
throw new UserInputError('must supply name', { argumentName: 'name' }) throw new UserInputError('must supply name', { argumentName: 'name' })
@ -232,6 +369,7 @@ export default {
FROM "Item" FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NOT NULL WHERE "userId" = $1 AND "parentId" IS NOT NULL
AND created_at <= $2 AND created_at <= $2
${await filterClause(me, models)}
ORDER BY created_at DESC ORDER BY created_at DESC
OFFSET $3 OFFSET $3
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset) LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
@ -243,7 +381,8 @@ export default {
WHERE "parentId" IS NOT NULL WHERE "parentId" IS NOT NULL
AND "Item".created_at <= $1 AND "Item".created_at <= $1
${topClause(within)} ${topClause(within)}
${TOP_ORDER_BY_SATS} ${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
OFFSET $2 OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break break
@ -293,8 +432,8 @@ export default {
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 3`, similar) LIMIT 3`, similar)
}, },
comments: async (parent, { id, sort }, { models }) => { comments: async (parent, { id, sort }, { me, models }) => {
return comments(models, id, sort) return comments(me, models, id, sort)
}, },
search: async (parent, { q: query, sub, cursor }, { me, models, search }) => { search: async (parent, { q: query, sub, cursor }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
@ -317,11 +456,19 @@ export default {
bool: { bool: {
should: [ should: [
{ match: { status: 'ACTIVE' } }, { match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } } { match: { userId: me.id } }
] ]
} }
} }
: { match: { status: 'ACTIVE' } }, : {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
},
{ {
bool: { bool: {
should: [ should: [
@ -390,8 +537,9 @@ export default {
} }
// return highlights // return highlights
const items = sitems.body.hits.hits.map(e => { const items = sitems.body.hits.hits.map(async e => {
const item = e._source // this is super inefficient but will suffice until we do something more generic
const item = await getItem(parent, { id: e._source.id }, { me, models })
item.searchTitle = (e.highlight.title && e.highlight.title[0]) || item.title item.searchTitle = (e.highlight.title && e.highlight.title[0]) || item.title
item.searchText = (e.highlight.text && e.highlight.text[0]) || item.text item.searchText = (e.highlight.text && e.highlight.text[0]) || item.text
@ -404,17 +552,24 @@ export default {
items items
} }
}, },
auctionPosition: async (parent, { id, sub, bid }, { models }) => { auctionPosition: async (parent, { id, sub, bid }, { models, me }) => {
// count items that have a bid gte to the current bid or // count items that have a bid gte to the current bid or
// gte current bid and older // gte current bid and older
const where = { const where = {
where: { where: {
subName: sub, subName: sub,
status: 'ACTIVE', status: { not: 'STOPPED' }
maxBid: {
gte: bid
} }
} }
if (bid > 0) {
where.where.maxBid = { gte: bid }
} else {
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
where.where.OR = [
{ maxBid: { gt: 0 } },
{ createdAt: { gt: createdAt } }
]
} }
if (id) { if (id) {
@ -431,8 +586,7 @@ export default {
data.url = ensureProtocol(data.url) data.url = ensureProtocol(data.url)
if (id) { if (id) {
const { forward, boost, ...remaining } = data return await updateItem(parent, { id, data }, { me, models })
return await updateItem(parent, { id, data: remaining }, { me, models })
} else { } else {
return await createItem(parent, data, { me, models }) return await createItem(parent, data, { me, models })
} }
@ -441,13 +595,63 @@ export default {
const { id, ...data } = args const { id, ...data } = args
if (id) { if (id) {
const { forward, boost, ...remaining } = data return await updateItem(parent, { id, data }, { me, models })
return await updateItem(parent, { id, data: remaining }, { me, models })
} else { } else {
return await createItem(parent, data, { me, models }) 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) { if (!me) {
throw new AuthenticationError('you must be logged in to create job') 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' }) throw new UserInputError('not a valid sub', { argumentName: 'sub' })
} }
if (fullSub.baseCost > maxBid) { if (maxBid < 0) {
throw new UserInputError(`bid must be at least ${fullSub.baseCost}`, { argumentName: 'maxBid' }) throw new UserInputError('bid must be at least 0', { argumentName: 'maxBid' })
} }
if (!location && !remote) { if (!location && !remote) {
throw new UserInputError('must specify location or remote', { argumentName: 'location' }) throw new UserInputError('must specify location or remote', { argumentName: 'location' })
} }
const checkSats = async () => { location = location.toLowerCase() === 'remote' ? undefined : location
// check if the user has the funds to run for the first minute
const minuteMsats = maxBid * 1000
const user = await models.user.findUnique({ where: { id: me.id } })
if (user.msats < minuteMsats) {
throw new UserInputError('insufficient funds')
}
}
const data = {
title,
company,
location: location.toLowerCase() === 'remote' ? undefined : location,
remote,
text,
url,
maxBid,
subName: sub,
userId: me.id
}
let item
if (id) { if (id) {
if (status) {
data.status = status
// if the job is changing to active, we need to check they have funds
if (status === 'ACTIVE') {
await checkSats()
}
}
const old = await models.item.findUnique({ where: { id: Number(id) } }) const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) { if (Number(old.userId) !== Number(me?.id)) {
throw new AuthenticationError('item does not belong to you') throw new AuthenticationError('item does not belong to you')
} }
([item] = await serialize(models,
return await models.item.update({ models.$queryRaw(
where: { id: Number(id) }, `${SELECT} FROM update_job($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) AS "Item"`,
data Number(id), title, url, text, Number(maxBid), company, location, remote, Number(logo), status)))
}) } else {
([item] = await serialize(models,
models.$queryRaw(
`${SELECT} FROM create_job($1, $2, $3, $4, $5, $6, $7, $8, $9) AS "Item"`,
title, url, text, Number(me.id), Number(maxBid), company, location, remote, Number(logo))))
} }
// before creating job, check the sats await createMentions(item, models)
await checkSats()
return await models.item.create({ return item
data
})
}, },
createComment: async (parent, { text, parentId }, { me, models }) => { createComment: async (parent, { text, parentId }, { me, models }) => {
return await createItem(parent, { text, parentId }, { me, models }) return await createItem(parent, { text, parentId }, { me, models })
@ -519,6 +698,17 @@ export default {
updateComment: async (parent, { id, text }, { me, models }) => { updateComment: async (parent, { id, text }, { me, models }) => {
return await updateItem(parent, { id, data: { 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 }) => { act: async (parent, { id, sats }, { me, models }) => {
// need to make sure we are logged in // need to make sure we are logged in
if (!me) { if (!me) {
@ -544,10 +734,31 @@ export default {
vote, vote,
sats sats
} }
},
dontLikeThis: async (parent, { id }, { me, models }) => {
// need to make sure we are logged in
if (!me) {
throw new AuthenticationError('you must be logged in')
}
// disallow self down votes
const [item] = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
if (item) {
throw new UserInputError('cannot downvote your self')
}
await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST})`)
return true
} }
}, },
Item: { Item: {
isJob: async (item, args, { models }) => {
return item.subName === 'jobs'
},
sub: async (item, args, { models }) => { sub: async (item, args, { models }) => {
if (!item.subName) { if (!item.subName) {
return null return null
@ -590,6 +801,27 @@ export default {
return prior.id 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 }) => user: async (item, args, { models }) =>
await models.user.findUnique({ where: { id: item.userId } }), await models.user.findUnique({ where: { id: item.userId } }),
fwdUser: async (item, args, { models }) => { fwdUser: async (item, args, { models }) => {
@ -598,36 +830,11 @@ export default {
} }
return await models.user.findUnique({ where: { id: item.fwdUserId } }) return await models.user.findUnique({ where: { id: item.fwdUserId } })
}, },
ncomments: async (item, args, { models }) => { comments: async (item, args, { me, 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 }) => {
if (item.comments) { if (item.comments) {
return item.comments return item.comments
} }
return comments(models, item.id, 'hot') return comments(me, models, item.id, 'hot')
},
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
}, },
upvotes: async (item, args, { models }) => { upvotes: async (item, args, { models }) => {
const { sum: { sats } } = await models.itemAct.aggregate({ const { sum: { sats } } = await models.itemAct.aggregate({
@ -681,10 +888,24 @@ export default {
return sats || 0 return sats || 0
}, },
meComments: async (item, args, { me, models }) => { meDontLike: async (item, args, { me, models }) => {
if (!me) return 0 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 }) => { mine: async (item, args, { me, models }) => {
return me?.id === item.userId 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 // update iff this item belongs to me
const old = await models.item.findUnique({ where: { id: Number(id) } }) const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) { if (Number(old.userId) !== Number(me?.id)) {
throw new AuthenticationError('item does not belong to you') throw new AuthenticationError('item does not belong to you')
} }
// if it's not the FAQ and older than 10 minutes // if it's not the FAQ, not their bio, and older than 10 minutes
if (old.id !== 349 && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) { 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') throw new UserInputError('item can no longer be editted')
} }
const item = await models.item.update({ if (boost && boost < BOOST_MIN) {
where: { id: Number(id) }, throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
data }
})
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) 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' }) 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 let fwdUser
if (forward) { if (forward) {
fwdUser = await models.user.findUnique({ where: { name: 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, const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6) AS "Item"`, models.$queryRaw(
title, url, text, Number(boost || 0), Number(parentId), Number(me.id))) `${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) await createMentions(item, models)
if (fwdUser) {
await models.item.update({
where: { id: item.id },
data: {
fwdUserId: fwdUser.id
}
})
}
item.comments = [] item.comments = []
return item return item
} }
@ -837,12 +1072,16 @@ export const SELECT =
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title, `SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
"Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid", "Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
"Item".company, "Item".location, "Item".remote, "Item".company, "Item".location, "Item".remote,
"Item"."subName", "Item".status, 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 ` return `
ORDER BY (POWER("Item"."weightedVotes", 1.2)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 1.3) + ORDER BY (${await orderByNumerator(me, models)}/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 1.3) +
GREATEST("Item".boost-1000+5, 0)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 4)) DESC NULLS LAST, "Item".id DESC` ("Item".boost/${BOOST_MIN}::float)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 2.6)) DESC NULLS LAST, "Item".id DESC`
} }
const TOP_ORDER_BY_SATS = 'ORDER BY "Item"."weightedVotes" DESC NULLS LAST, "Item".id DESC' async function topOrderByWeightedSats (me, models) {
return `ORDER BY ${await orderByNumerator(me, models)} DESC NULLS LAST, "Item".id DESC`
}

View File

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

View File

@ -29,6 +29,15 @@ async function serialize (models, call) {
if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) { if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) {
bail(new Error('faucet has been revoked or is 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')) { if (error.message.includes('40001')) {
throw new Error('wallet balance serialization failure - retry again') throw new Error('wallet balance serialization failure - retry again')
} }

View File

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

View File

@ -1,6 +1,6 @@
import { AuthenticationError, UserInputError } from 'apollo-server-errors' import { AuthenticationError, UserInputError } from 'apollo-server-errors'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' 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' import serialize from './serial'
export function topClause (within) { export function topClause (within) {
@ -133,6 +133,10 @@ export default {
cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
users 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') 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 { try {
await models.user.update({ where: { id: me.id }, data: { name } }) await models.user.update({ where: { id: me.id }, data: { name } })
} catch (error) { } catch (error) {
@ -156,9 +168,7 @@ export default {
throw new AuthenticationError('you must be logged in') throw new AuthenticationError('you must be logged in')
} }
await models.user.update({ where: { id: me.id }, data }) return await models.user.update({ where: { id: me.id }, data })
return true
}, },
setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => { setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => {
if (!me) { if (!me) {
@ -188,21 +198,14 @@ export default {
const user = await models.user.findUnique({ where: { id: me.id } }) const user = await models.user.findUnique({ where: { id: me.id } })
let item
if (user.bioId) { if (user.bioId) {
item = await models.item.update({ await updateItem(parent, { id: user.bioId, data: { text: bio, title: `@${user.name}'s bio` } }, { me, models })
where: { id: Number(user.bioId) },
data: {
text: bio
}
})
} else { } else {
([item] = await serialize(models, const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM create_bio($1, $2, $3) AS "Item"`, 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 } }) return await models.user.findUnique({ where: { id: me.id } })
}, },
@ -239,7 +242,10 @@ export default {
} }
try { try {
await models.user.update({ where: { id: me.id }, data: { email } }) await models.user.update({
where: { id: me.id },
data: { email: email.toLowerCase() }
})
} catch (error) { } catch (error) {
if (error.code === 'P2002') { if (error.code === 'P2002') {
throw new UserInputError('email taken') throw new UserInputError('email taken')
@ -308,6 +314,7 @@ export default {
JOIN "Item" p ON ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} JOIN "Item" p ON ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
WHERE p."userId" = $1 WHERE p."userId" = $1
AND "Item".created_at > $2 AND "Item"."userId" <> $1 AND "Item".created_at > $2 AND "Item"."userId" <> $1
${await filterClause(me, models)}
LIMIT 1`, me.id, lastChecked) LIMIT 1`, me.id, lastChecked)
if (newReplies.length > 0) { if (newReplies.length > 0) {
return true return true
@ -330,9 +337,6 @@ export default {
const job = await models.item.findFirst({ const job = await models.item.findFirst({
where: { where: {
status: {
not: 'STOPPED'
},
maxBid: { maxBid: {
not: null not: null
}, },

View File

@ -1,29 +1,11 @@
import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service' 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 serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import lnpr from 'bolt11' import lnpr from 'bolt11'
import { SELECT } from './item' import { SELECT } from './item'
import { lnurlPayDescriptionHash } from '../../lib/lnurl' 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 }) { export async function getInvoice (parent, { id }, { me, models }) {
if (!me) { if (!me) {
throw new AuthenticationError('you must be logged in') throw new AuthenticationError('you must be logged in')
@ -121,11 +103,12 @@ export default {
AND "ItemAct".created_at <= $2 AND "ItemAct".created_at <= $2
GROUP BY "Item".id)`) GROUP BY "Item".id)`)
queries.push( queries.push(
`(SELECT ('earn' || "Earn".id) as id, "Earn".id as "factId", NULL as bolt11, `(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11,
created_at as "createdAt", msats, created_at as "createdAt", sum(msats),
0 as "msatsFee", NULL as status, 'earn' as type 0 as "msatsFee", NULL as status, 'earn' as type
FROM "Earn" FROM "Earn"
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2)`) WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
GROUP BY "userId", created_at)`)
} }
if (include.has('spent')) { if (include.has('spent')) {
@ -199,16 +182,12 @@ export default {
const user = await models.user.findUnique({ where: { id: me.id } }) 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 // set expires at to 3 hours into future
const expiresAt = new Date(new Date().setHours(new Date().getHours() + 3)) const expiresAt = new Date(new Date().setHours(new Date().getHours() + 3))
const description = `${amount} sats for @${user.name} on stacker.news` const description = `${amount} sats for @${user.name} on stacker.news`
try { try {
const invoice = await createInvoice({ const invoice = await createInvoice({
description, description: user.hideInvoiceDesc ? undefined : description,
lnd, lnd,
tokens: amount, tokens: amount,
expires_at: expiresAt expires_at: expiresAt

View File

@ -35,8 +35,29 @@ export default async function getSSRApolloClient (req, me = null) {
export function getGetServerSideProps (query, variables = null, notFoundFunc, requireVar) { export function getGetServerSideProps (query, variables = null, notFoundFunc, requireVar) {
return async function ({ req, query: params }) { return async function ({ req, query: params }) {
const { nodata, ...realParams } = params
const vars = { ...realParams, ...variables }
const client = await getSSRApolloClient(req) 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]) { if (requireVar && !vars[requireVar]) {
return { return {
@ -60,17 +81,11 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re
props = { props = {
apollo: { apollo: {
query: print(query), query: print(query),
variables: { ...params, ...variables } variables: vars
} }
} }
} }
const { data: { me } } = await client.query({
query: ME_SSR
})
const price = await getPrice()
return { return {
props: { props: {
...props, ...props,

View File

@ -11,6 +11,10 @@ export default gql`
allItems(cursor: String): Items allItems(cursor: String): Items
search(q: String, sub: String, cursor: String): Items search(q: String, sub: String, cursor: String): Items
auctionPosition(sub: String, id: ID, bid: Int!): Int! auctionPosition(sub: String, id: ID, bid: Int!): Int!
itemRepetition(parentId: ID): Int!
outlawedItems(cursor: String): Items
borderlandItems(cursor: String): Items
freebieItems(cursor: String): Items
} }
type ItemActResult { type ItemActResult {
@ -21,10 +25,27 @@ export default gql`
extend type Mutation { extend type Mutation {
upsertLink(id: ID, title: String!, url: String!, boost: Int, forward: String): Item! upsertLink(id: ID, title: String!, url: String!, boost: Int, forward: String): Item!
upsertDiscussion(id: ID, title: String!, text: 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! createComment(text: String!, parentId: ID!): Item!
updateComment(id: ID!, text: String!): Item! updateComment(id: ID!, text: String!): Item!
dontLikeThis(id: ID!): Boolean!
act(id: ID!, sats: Int): ItemActResult! act(id: ID!, sats: Int): ItemActResult!
pollVote(id: ID!): ID!
}
type PollOption {
id: ID,
option: String!
count: Int!
meVoted: Boolean!
}
type Poll {
meVoted: Boolean!
count: Int!
options: [PollOption!]!
} }
type Items { type Items {
@ -57,19 +78,28 @@ export default gql`
mine: Boolean! mine: Boolean!
boost: Int! boost: Int!
sats: Int! sats: Int!
commentSats: Int!
lastCommentAt: String
upvotes: Int! upvotes: Int!
meSats: Int! meSats: Int!
meComments: Int! meDontLike: Boolean!
outlawed: Boolean!
freebie: Boolean!
paidImgLink: Boolean
ncomments: Int! ncomments: Int!
comments: [Item!]! comments: [Item!]!
path: String path: String
position: Int position: Int
prior: Int prior: Int
maxBid: Int maxBid: Int
isJob: Boolean!
pollCost: Int
poll: Poll
company: String company: String
location: String location: String
remote: Boolean remote: Boolean
sub: Sub sub: Sub
status: String status: String
uploadId: Int
} }
` `

View File

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

View File

@ -8,6 +8,7 @@ export default gql`
users: [User!] users: [User!]
nameAvailable(name: String!): Boolean! nameAvailable(name: String!): Boolean!
topUsers(cursor: String, within: String!, userType: String!): TopUsers topUsers(cursor: String, within: String!, userType: String!): TopUsers
searchUsers(name: String!): [User!]!
} }
type Users { type Users {
@ -30,7 +31,8 @@ export default gql`
setName(name: String!): Boolean setName(name: String!): Boolean
setSettings(tipDefault: Int!, noteItemSats: Boolean!, noteEarning: Boolean!, setSettings(tipDefault: Int!, noteItemSats: Boolean!, noteEarning: Boolean!,
noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: 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! setPhoto(photoId: ID!): Int!
upsertBio(bio: String!): User! upsertBio(bio: String!): User!
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
@ -70,6 +72,9 @@ export default gql`
noteDeposits: Boolean! noteDeposits: Boolean!
noteInvites: Boolean! noteInvites: Boolean!
noteJobIndicator: Boolean! noteJobIndicator: Boolean!
hideInvoiceDesc: Boolean!
wildWestMode: Boolean!
greeterMode: Boolean!
lastCheckedJobs: String lastCheckedJobs: String
authMethods: AuthMethods! authMethods: AuthMethods!
} }

View File

@ -1,7 +1,7 @@
import { useFormikContext } from 'formik' import { useFormikContext } from 'formik'
import { OverlayTrigger, Tooltip } from 'react-bootstrap' 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 // if we're in a form, we want to hide tooltip on submit
let formik let formik
if (!notForm) { if (!notForm) {
@ -12,7 +12,7 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText
} }
return ( return (
<OverlayTrigger <OverlayTrigger
placement='bottom' placement={placement || 'bottom'}
overlay={ overlay={
<Tooltip> <Tooltip>
{overlayText || '1 sat'} {overlayText || '1 sat'}

View File

@ -1,14 +1,22 @@
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import * as Yup from 'yup' import * as Yup from 'yup'
import { Input } from './form' import { Input, InputUserSuggest } from './form'
import { InputGroup } from 'react-bootstrap' import { InputGroup } from 'react-bootstrap'
import { BOOST_MIN } from '../lib/constants' import { BOOST_MIN } from '../lib/constants'
import { NAME_QUERY } from '../fragments/users' import { NAME_QUERY } from '../fragments/users'
import Info from './info'
export function AdvPostSchema (client) { export function AdvPostSchema (client) {
return { return {
boost: Yup.number().typeError('must be a number') 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() forward: Yup.string()
.test({ .test({
name: 'name', name: 'name',
@ -22,28 +30,50 @@ export function AdvPostSchema (client) {
} }
} }
export const AdvPostInitial = { export function AdvPostInitial ({ forward }) {
return {
boost: '', boost: '',
forward: '' forward: forward || ''
}
} }
export default function AdvPostForm () { export default function AdvPostForm ({ edit }) {
return ( return (
<AccordianItem <AccordianItem
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>} header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
body={ body={
<> <>
<Input <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' name='boost'
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>} hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/> />
<Input <InputUserSuggest
label={<>forward sats to</>} label={<>forward sats to</>}
name='forward' name='forward'
hint={<span className='text-muted'>100% of sats will be sent to this user</span>} hint={<span className='text-muted'>100% of sats will be sent to this user</span>}
prepend=<InputGroup.Text>@</InputGroup.Text> prepend={<InputGroup.Text>@</InputGroup.Text>}
showValid showValid
/> />
</> </>

74
components/avatar.js Normal file
View File

@ -0,0 +1,74 @@
import { useRef, useState } from 'react'
import AvatarEditor from 'react-avatar-editor'
import { Button, Modal, Form as BootstrapForm } from 'react-bootstrap'
import Upload from './upload'
import EditImage from '../svgs/image-edit-fill.svg'
import Moon from '../svgs/moon-fill.svg'
export default function Avatar ({ onSuccess }) {
const [uploading, setUploading] = useState()
const [editProps, setEditProps] = useState()
const ref = useRef()
const [scale, setScale] = useState(1)
return (
<>
<Modal
show={!!editProps}
onHide={() => setEditProps(null)}
>
<div className='modal-close' onClick={() => setEditProps(null)}>X</div>
<Modal.Body className='text-right mt-1 p-4'>
<AvatarEditor
ref={ref} width={200} height={200}
image={editProps?.file}
scale={scale}
style={{
width: '100%',
height: 'auto'
}}
/>
<BootstrapForm.Group controlId='formBasicRange'>
<BootstrapForm.Control
type='range' onChange={e => setScale(parseFloat(e.target.value))}
min={1} max={2} step='0.05'
defaultValue={scale} custom
/>
</BootstrapForm.Group>
<Button onClick={() => {
ref.current.getImageScaledToCanvas().toBlob(blob => {
if (blob) {
editProps.upload(blob)
setEditProps(null)
}
}, 'image/jpeg')
}}
>save
</Button>
</Modal.Body>
</Modal>
<Upload
as={({ onClick }) =>
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
{uploading
? <Moon className='fill-white spin' />
: <EditImage className='fill-white' />}
</div>}
onError={e => {
console.log(e)
setUploading(false)
}}
onSelect={(file, upload) => {
setEditProps({ file, upload })
}}
onSuccess={async key => {
onSuccess && onSuccess(key)
setUploading(false)
}}
onStarted={() => {
setUploading(true)
}}
/>
</>
)
}

View File

@ -3,6 +3,7 @@ import * as Yup from 'yup'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css' import styles from './reply.module.css'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { EditFeeButton } from './fee-button'
export const CommentSchema = Yup.object({ export const CommentSchema = Yup.object({
text: Yup.string().required('required').trim() text: Yup.string().required('required').trim()
@ -53,7 +54,10 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
autoFocus autoFocus
required required
/> />
<SubmitButton variant='secondary' className='mt-1'>save</SubmitButton> <EditFeeButton
paidSats={comment.meSats}
parentId={comment.parentId} text='save' ChildButton={SubmitButton} variant='secondary'
/>
</Form> </Form>
</div> </div>
) )

View File

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

View File

@ -1,12 +1,21 @@
.item { .item {
align-items: flex-start; align-items: flex-start;
margin-bottom: 0 !important; margin-bottom: 0 !important;
padding-bottom: 0 !important;
} }
.upvote { .upvote {
margin-top: 9px; margin-top: 9px;
} }
.dontLike {
fill: #a5a5a5;
margin-right: .2rem;
padding: 2px;
margin-left: 1px;
margin-top: 9px;
}
.text { .text {
margin-top: .1rem; margin-top: .1rem;
padding-right: 15px; padding-right: 15px;
@ -77,7 +86,6 @@
} }
.hunk { .hunk {
overflow: visible;
margin-bottom: 0; margin-bottom: 0;
margin-top: 0.15rem; margin-top: 0.15rem;
} }

View File

@ -6,7 +6,7 @@ import { Nav, Navbar } from 'react-bootstrap'
import { COMMENTS_QUERY } from '../fragments/items' import { COMMENTS_QUERY } from '../fragments/items'
import { COMMENTS } from '../fragments/comments' import { COMMENTS } from '../fragments/comments'
export function CommentsHeader ({ handleSort }) { export function CommentsHeader ({ handleSort, commentSats }) {
const [sort, setSort] = useState('hot') const [sort, setSort] = useState('hot')
const getHandleClick = sort => { const getHandleClick = sort => {
@ -17,11 +17,15 @@ export function CommentsHeader ({ handleSort }) {
} }
return ( return (
<Navbar className='py-1'> <Navbar className='pt-1 pb-0'>
<Nav <Nav
className={styles.navbarNav} className={styles.navbarNav}
activeKey={sort} activeKey={sort}
> >
<Nav.Item className='text-muted'>
{commentSats} sats
</Nav.Item>
<div className='ml-auto d-flex'>
<Nav.Item> <Nav.Item>
<Nav.Link <Nav.Link
eventKey='hot' eventKey='hot'
@ -49,20 +53,24 @@ export function CommentsHeader ({ handleSort }) {
top top
</Nav.Link> </Nav.Link>
</Nav.Item> </Nav.Item>
</div>
</Nav> </Nav>
</Navbar> </Navbar>
) )
} }
export default function Comments ({ parentId, comments, ...props }) { export default function Comments ({ parentId, commentSats, comments, ...props }) {
const client = useApolloClient() const client = useApolloClient()
useEffect(() => { useEffect(() => {
const hash = window.location.hash const hash = window.location.hash
if (hash) { if (hash) {
try {
document.querySelector(hash).scrollIntoView({ behavior: 'smooth' }) 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', fetchPolicy: 'network-only',
onCompleted: data => { onCompleted: data => {
client.writeFragment({ client.writeFragment({
@ -80,12 +88,20 @@ export default function Comments ({ parentId, comments, ...props }) {
comments: data.comments comments: data.comments
} }
}) })
setLoading(false)
} }
}) })
return ( 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 {loading
? <CommentsSkeleton /> ? <CommentsSkeleton />
: comments.map(item => ( : comments.map(item => (

View File

@ -2,11 +2,11 @@ import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import * as Yup from 'yup' import * as Yup from 'yup'
import { gql, useApolloClient, useMutation } from '@apollo/client' import { gql, useApolloClient, useMutation } from '@apollo/client'
import ActionTooltip from '../components/action-tooltip'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import Countdown from './countdown' import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
import { MAX_TITLE_LENGTH } from '../lib/constants' import { MAX_TITLE_LENGTH } from '../lib/constants'
import FeeButton, { EditFeeButton } from './fee-button'
export function DiscussionForm ({ export function DiscussionForm ({
item, editThreshold, titleLabel = 'title', item, editThreshold, titleLabel = 'title',
@ -15,6 +15,7 @@ export function DiscussionForm ({
}) { }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
// const me = useMe()
const [upsertDiscussion] = useMutation( const [upsertDiscussion] = useMutation(
gql` gql`
mutation upsertDiscussion($id: ID, $title: String!, $text: String, $boost: Int, $forward: String) { mutation upsertDiscussion($id: ID, $title: String!, $text: String, $boost: Int, $forward: String) {
@ -31,12 +32,15 @@ export function DiscussionForm ({
...AdvPostSchema(client) ...AdvPostSchema(client)
}) })
// const cost = linkOrImg ? 10 : me?.freePosts ? 0 : 1
return ( return (
<Form <Form
initial={{ initial={{
title: item?.title || '', title: item?.title || '',
text: item?.text || '', text: item?.text || '',
...AdvPostInitial suggest: '',
...AdvPostInitial({ forward: item?.fwdUser?.name })
}} }}
schema={DiscussionSchema} schema={DiscussionSchema}
onSubmit={handleSubmit || (async ({ boost, ...values }) => { onSubmit={handleSubmit || (async ({ boost, ...values }) => {
@ -60,6 +64,7 @@ export function DiscussionForm ({
name='title' name='title'
required required
autoFocus autoFocus
clear
/> />
<MarkdownInput <MarkdownInput
topLevel topLevel
@ -71,10 +76,18 @@ export function DiscussionForm ({
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div> ? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
: null} : null}
/> />
{!item && adv && <AdvPostForm />} {adv && <AdvPostForm edit={!!item} />}
<ActionTooltip> <div className='mt-3'>
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : buttonText}</SubmitButton> {item
</ActionTooltip> ? <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> </Form>
) )
} }

View File

@ -0,0 +1,54 @@
import { gql, useMutation } from '@apollo/client'
import { Dropdown } from 'react-bootstrap'
import MoreIcon from '../svgs/more-fill.svg'
import { useFundError } from './fund-error'
export default function DontLikeThis ({ id }) {
const { setError } = useFundError()
const [dontLikeThis] = useMutation(
gql`
mutation dontLikeThis($id: ID!) {
dontLikeThis(id: $id)
}`, {
update (cache) {
cache.modify({
id: `Item:${id}`,
fields: {
meDontLike () {
return true
}
}
})
}
}
)
return (
<Dropdown className='pointer' as='span'>
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
<MoreIcon className='fill-grey ml-1' height={16} width={16} />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
className='text-center'
onClick={async () => {
try {
await dontLikeThis({
variables: { id },
optimisticResponse: { dontLikeThis: true }
})
} catch (error) {
if (error.toString().includes('insufficient funds')) {
setError(true)
}
}
}}
>
flag
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
)
}

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

@ -0,0 +1,119 @@
import { Table } from 'react-bootstrap'
import ActionTooltip from './action-tooltip'
import Info from './info'
import styles from './fee-button.module.css'
import { gql, useQuery } from '@apollo/client'
import { useFormikContext } from 'formik'
function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
return (
<Table className={styles.receipt} borderless size='sm'>
<tbody>
<tr>
<td>{baseFee} sats</td>
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
</tr>
{hasImgLink &&
<tr>
<td>x 10</td>
<td align='right' className='font-weight-light'>image/link fee</td>
</tr>}
{repetition > 0 &&
<tr>
<td>x 10<sup>{repetition}</sup></td>
<td className='font-weight-light' align='right'>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</td>
</tr>}
{boost > 0 &&
<tr>
<td>+ {boost} sats</td>
<td className='font-weight-light' align='right'>boost</td>
</tr>}
</tbody>
<tfoot>
<tr>
<td className='font-weight-bold'>{cost} sats</td>
<td align='right' className='font-weight-light'>total fee</td>
</tr>
</tfoot>
</Table>
)
}
export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow }) {
const query = parentId
? gql`{ itemRepetition(parentId: "${parentId}") }`
: gql`{ itemRepetition }`
const { data } = useQuery(query, { pollInterval: 1000 })
const repetition = data?.itemRepetition || 0
const formik = useFormikContext()
const boost = formik?.values?.boost || 0
const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost)
const show = alwaysShow || !formik?.isSubmitting
return (
<div className='d-flex align-items-center'>
<ActionTooltip overlayText={`${cost} sats`}>
<ChildButton variant={variant}>{text}{cost > baseFee && show && <small> {cost} sats</small>}</ChildButton>
</ActionTooltip>
{cost > baseFee && show &&
<Info>
<Receipt baseFee={baseFee} hasImgLink={hasImgLink} repetition={repetition} cost={cost} parentId={parentId} boost={boost} />
</Info>}
</div>
)
}
function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) {
return (
<Table className={styles.receipt} borderless size='sm'>
<tbody>
{addImgLink &&
<>
<tr>
<td>{paidSats} sats</td>
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
</tr>
<tr>
<td>x 10</td>
<td align='right' className='font-weight-light'>image/link fee</td>
</tr>
<tr>
<td>- {paidSats} sats</td>
<td align='right' className='font-weight-light'>already paid</td>
</tr>
</>}
{boost > 0 &&
<tr>
<td>+ {boost} sats</td>
<td className='font-weight-light' align='right'>boost</td>
</tr>}
</tbody>
<tfoot>
<tr>
<td className='font-weight-bold'>{cost} sats</td>
<td align='right' className='font-weight-light'>total fee</td>
</tr>
</tfoot>
</Table>
)
}
export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, variant, text, alwaysShow, parentId }) {
const formik = useFormikContext()
const boost = formik?.values?.boost || 0
const addImgLink = hasImgLink && !hadImgLink
const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost)
const show = alwaysShow || !formik?.isSubmitting
return (
<div className='d-flex align-items-center'>
<ActionTooltip overlayText={`${cost} sats`}>
<ChildButton variant={variant}>{text}{cost > 0 && show && <small> {cost} sats</small>}</ChildButton>
</ActionTooltip>
{cost > 0 && show &&
<Info>
<EditReceipt paidSats={paidSats} addImgLink={addImgLink} cost={cost} parentId={parentId} boost={boost} />
</Info>}
</div>
)
}

View File

@ -0,0 +1,15 @@
.receipt {
background-color: var(--theme-inputBg);
max-width: 250px;
margin: auto;
table-layout: auto;
width: 100%;
}
.receipt td {
padding: .25rem .1rem;
}
.receipt tfoot {
border-top: 2px solid var(--theme-borderColor);
}

View File

@ -34,7 +34,7 @@ const COLORS = {
grey: '#707070', grey: '#707070',
link: '#007cbe', link: '#007cbe',
linkHover: '#004a72', linkHover: '#004a72',
linkVisited: '#7acaf5' linkVisited: '#537587'
}, },
dark: { dark: {
body: '#000000', body: '#000000',
@ -53,7 +53,7 @@ const COLORS = {
grey: '#969696', grey: '#969696',
link: '#2e99d1', link: '#2e99d1',
linkHover: '#007cbe', linkHover: '#007cbe',
linkVisited: '#066ba3' linkVisited: '#56798E'
} }
} }
@ -96,7 +96,7 @@ const AnalyticsPopover = (
visitors visitors
</a> </a>
<span className='mx-2 text-dark'> \ </span> <span className='mx-2 text-dark'> \ </span>
<Link href='/users/forever' passHref> <Link href='/users/week' passHref>
<a className='text-dark d-inline-flex'> <a className='text-dark d-inline-flex'>
users users
</a> </a>
@ -129,13 +129,27 @@ export default function Footer ({ noLinks }) {
<footer> <footer>
<Container className='mb-3 mt-4'> <Container className='mb-3 mt-4'>
{!noLinks && {!noLinks &&
<div className='mb-2' style={{ fontWeight: 500 }}> <>
{mounted && {mounted &&
<div className='mb-2'> <div className='mb-2'>
{darkMode.value {darkMode.value
? <Sun onClick={() => darkMode.toggle()} className='fill-grey theme' /> ? <Sun onClick={() => darkMode.toggle()} className='fill-grey theme' />
: <Moon onClick={() => darkMode.toggle()} className='fill-grey theme' />} : <Moon onClick={() => darkMode.toggle()} className='fill-grey theme' />}
</div>} </div>}
<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> <Link href='/faq' passHref>
<a className='nav-link p-0 d-inline-flex'> <a className='nav-link p-0 d-inline-flex'>
faq faq
@ -148,22 +162,15 @@ export default function Footer ({ noLinks }) {
</a> </a>
</Link> </Link>
<span className='mx-2 text-muted'> \ </span> <span className='mx-2 text-muted'> \ </span>
<OverlayTrigger trigger='click' placement='top' overlay={AnalyticsPopover} rootClose> <a href='/privacy' className='nav-link p-0 d-inline-flex' target='_blank'>
<div className='nav-link p-0 d-inline-flex' style={{ cursor: 'pointer' }}> privacy
analytics </a>
</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> <span className='mx-2 text-muted'> \ </span>
<a href='/rss' className='nav-link p-0 d-inline-flex' target='_blank'> <a href='/rss' className='nav-link p-0 d-inline-flex' target='_blank'>
rss rss
</a> </a>
</div>} </div>
</>}
{data && {data &&
<div <div
className={`text-small mx-auto mb-1 ${styles.connect}`} className={`text-small mx-auto mb-1 ${styles.connect}`}
@ -173,6 +180,7 @@ export default function Footer ({ noLinks }) {
size='sm' size='sm'
groupClassName='mb-0 w-100' groupClassName='mb-0 w-100'
readOnly readOnly
noForm
placeholder={data.connectAddress} placeholder={data.connectAddress}
/> />
</div>} </div>}

View File

@ -2,14 +2,19 @@ import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import BootstrapForm from 'react-bootstrap/Form' import BootstrapForm from 'react-bootstrap/Form'
import Alert from 'react-bootstrap/Alert' 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 React, { useEffect, useRef, useState } from 'react'
import copy from 'clipboard-copy' import copy from 'clipboard-copy'
import Thumb from '../svgs/thumb-up-fill.svg' 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 Markdown from '../svgs/markdown-line.svg'
import styles from './form.module.css' import styles from './form.module.css'
import Text from '../components/text' 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 ({ export function SubmitButton ({
children, variant, value, onClick, ...props 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 [tab, setTab] = useState('write')
const [, meta] = useField(props) const [, meta] = useField(props)
@ -98,7 +103,12 @@ export function MarkdownInput ({ label, topLevel, groupClassName, ...props }) {
</Nav> </Nav>
<div className={tab !== 'write' ? 'd-none' : ''}> <div className={tab !== 'write' ? 'd-none' : ''}>
<InputInner <InputInner
{...props} {...props} onChange={(formik, e) => {
if (onChange) onChange(formik, e)
if (setHasImgLink) {
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
}
}}
/> />
</div> </div>
<div className={tab !== 'preview' ? 'd-none' : 'form-group'}> <div className={tab !== 'preview' ? 'd-none' : 'form-group'}>
@ -122,10 +132,10 @@ function FormGroup ({ className, label, children }) {
function InputInner ({ function InputInner ({
prepend, append, hint, showValid, onChange, overrideValue, prepend, append, hint, showValid, onChange, overrideValue,
innerRef, storageKeyPrefix, ...props innerRef, storageKeyPrefix, noForm, clear, onKeyDown, ...props
}) { }) {
const [field, meta, helpers] = props.readOnly ? [{}, {}, {}] : useField(props) const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
const formik = props.readOnly ? null : useFormikContext() const formik = noForm ? null : useFormikContext()
const storageKey = storageKeyPrefix ? storageKeyPrefix + '-' + props.name : undefined const storageKey = storageKeyPrefix ? storageKeyPrefix + '-' + props.name : undefined
@ -145,6 +155,8 @@ function InputInner ({
} }
}, [overrideValue]) }, [overrideValue])
const invalid = meta.touched && meta.error
return ( return (
<> <>
<InputGroup hasValidation> <InputGroup hasValidation>
@ -158,6 +170,7 @@ function InputInner ({
if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) { if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) {
formik?.submitForm() formik?.submitForm()
} }
if (onKeyDown) onKeyDown(e)
}} }}
ref={innerRef} ref={innerRef}
{...field} {...props} {...field} {...props}
@ -172,11 +185,23 @@ function InputInner ({
onChange(formik, e) onChange(formik, e)
} }
}} }}
isInvalid={meta.touched && meta.error} isInvalid={invalid}
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error} isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
/> />
{append && ( {(append || (clear && field.value)) && (
<InputGroup.Append> <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} {append}
</InputGroup.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 }) { export function Input ({ label, groupClassName, ...props }) {
return ( return (
<FormGroup label={label} className={groupClassName}> <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 }) { export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, handleChange, inline, ...props }) {
// React treats radios and checkbox inputs differently other input types, select, and textarea. // 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 // Formik does this too! When you specify `type` to useField(), it will
@ -243,11 +371,17 @@ export function Form ({
validationSchema={schema} validationSchema={schema}
initialTouched={validateImmediately && initial} initialTouched={validateImmediately && initial}
validateOnBlur={false} validateOnBlur={false}
onSubmit={async (...args) => onSubmit={async (values, ...args) =>
onSubmit && onSubmit(...args).then(() => { onSubmit && onSubmit(values, ...args).then(() => {
if (!storageKeyPrefix) return if (!storageKeyPrefix) return
Object.keys(...args).forEach(v => Object.keys(values).forEach(v => {
localStorage.removeItem(storageKeyPrefix + '-' + 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))} }).catch(e => setError(e.message || e))}
> >
<FormikForm {...props} noValidate> <FormikForm {...props} noValidate>

View File

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

View File

@ -14,6 +14,7 @@ import { randInRange } from '../lib/rand'
import { formatSats } from '../lib/format' import { formatSats } from '../lib/format'
import NoteIcon from '../svgs/notification-4-fill.svg' import NoteIcon from '../svgs/notification-4-fill.svg'
import { useQuery, gql } from '@apollo/client' import { useQuery, gql } from '@apollo/client'
import LightningIcon from '../svgs/bolt.svg'
function WalletSummary ({ me }) { function WalletSummary ({ me }) {
if (!me) return null if (!me) return null
@ -27,6 +28,8 @@ export default function Header ({ sub }) {
const [fired, setFired] = useState() const [fired, setFired] = useState()
const me = useMe() const me = useMe()
const prefix = sub ? `/~${sub}` : '' 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` const { data: subLatestPost } = useQuery(gql`
query subLatestPost($name: ID!) { query subLatestPost($name: ID!) {
subLatestPost(name: $name) subLatestPost(name: $name)
@ -53,7 +56,7 @@ export default function Header ({ sub }) {
<link rel='shortcut icon' href={me?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} /> <link rel='shortcut icon' href={me?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
</Head> </Head>
<Link href='/notifications' passHref> <Link href='/notifications' passHref>
<Nav.Link className='pl-0 position-relative'> <Nav.Link eventKey='notifications' className='pl-0 position-relative'>
<NoteIcon /> <NoteIcon />
{me?.hasNewNotes && {me?.hasNewNotes &&
<span className={styles.notification}> <span className={styles.notification}>
@ -65,12 +68,12 @@ export default function Header ({ sub }) {
<NavDropdown <NavDropdown
className={styles.dropdown} title={ className={styles.dropdown} title={
<Link href={`/${me?.name}`} passHref> <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> </Link>
} alignRight } alignRight
> >
<Link href={'/' + me?.name} passHref> <Link href={'/' + me?.name} passHref>
<NavDropdown.Item> <NavDropdown.Item eventKey={me?.name}>
profile profile
{me && !me.bioId && {me && !me.bioId &&
<div className='p-1 d-inline-block bg-secondary ml-1'> <div className='p-1 d-inline-block bg-secondary ml-1'>
@ -79,14 +82,14 @@ export default function Header ({ sub }) {
</NavDropdown.Item> </NavDropdown.Item>
</Link> </Link>
<Link href='/wallet' passHref> <Link href='/wallet' passHref>
<NavDropdown.Item>wallet</NavDropdown.Item> <NavDropdown.Item eventKey='wallet'>wallet</NavDropdown.Item>
</Link> </Link>
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref> <Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref>
<NavDropdown.Item>satistics</NavDropdown.Item> <NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
</Link> </Link>
<NavDropdown.Divider /> <NavDropdown.Divider />
<Link href='/invites' passHref> <Link href='/invites' passHref>
<NavDropdown.Item>invites <NavDropdown.Item eventKey='invites'>invites
{me && !me.hasInvites && {me && !me.hasInvites &&
<div className='p-1 d-inline-block bg-success ml-1'> <div className='p-1 d-inline-block bg-success ml-1'>
<span className='invisible'>{' '}</span> <span className='invisible'>{' '}</span>
@ -96,7 +99,7 @@ export default function Header ({ sub }) {
<NavDropdown.Divider /> <NavDropdown.Divider />
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
<Link href='/settings' passHref> <Link href='/settings' passHref>
<NavDropdown.Item>settings</NavDropdown.Item> <NavDropdown.Item eventKey='settings'>settings</NavDropdown.Item>
</Link> </Link>
</div> </div>
<NavDropdown.Divider /> <NavDropdown.Divider />
@ -110,7 +113,7 @@ export default function Header ({ sub }) {
{me && {me &&
<Nav.Item> <Nav.Item>
<Link href='/wallet' passHref> <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> </Link>
</Nav.Item>} </Nav.Item>}
</div> </div>
@ -123,22 +126,36 @@ export default function Header ({ sub }) {
setFired(true) setFired(true)
}, [router.asPath]) }, [router.asPath])
} }
return path !== '/login' && !path.startsWith('/invites') && <Button id='login' onClick={signIn}>login</Button> return path !== '/login' && !path.startsWith('/invites') &&
<Button
className='align-items-center d-flex pl-2 pr-3'
id='login'
onClick={() => signIn(null, { callbackUrl: window.location.origin + router.asPath })}
>
<LightningIcon
width={17}
height={17}
className='mr-1'
/>login
</Button>
} }
} }
const showJobIndicator = sub !== 'jobs' && (!me || me.noteJobIndicator) &&
(!lastCheckedJobs || lastCheckedJobs < subLatestPost?.subLatestPost)
const NavItems = ({ className }) => { const NavItems = ({ className }) => {
return ( return (
<> <>
<Nav.Item className={className}> <Nav.Item className={className}>
<Link href={prefix + '/recent'} passHref> <Link href={prefix + '/recent'} passHref>
<Nav.Link className={styles.navLink}>recent</Nav.Link> <Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
</Link> </Link>
</Nav.Item> </Nav.Item>
{!prefix && {!prefix &&
<Nav.Item className={className}> <Nav.Item className={className}>
<Link href='/top/posts/week' passHref> <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> </Link>
</Nav.Item>} </Nav.Item>}
<Nav.Item className={className}> <Nav.Item className={className}>
@ -148,7 +165,7 @@ export default function Header ({ sub }) {
jobs jobs
</Nav.Link> </Nav.Link>
</Link> </Link>
{sub !== 'jobs' && (!me || me.noteJobIndicator) && (!lastCheckedJobs || lastCheckedJobs < subLatestPost?.subLatestPost) && {showJobIndicator &&
<span className={styles.jobIndicator}> <span className={styles.jobIndicator}>
<span className='invisible'>{' '}</span> <span className='invisible'>{' '}</span>
</span>} </span>}
@ -157,7 +174,7 @@ export default function Header ({ sub }) {
{me && {me &&
<Nav.Item className={className}> <Nav.Item className={className}>
<Link href={prefix + '/post'} passHref> <Link href={prefix + '/post'} passHref>
<Nav.Link className={styles.navLinkButton}>post</Nav.Link> <Nav.Link eventKey='post' className={styles.navLinkButton}>post</Nav.Link>
</Link> </Link>
</Nav.Item>} </Nav.Item>}
</> </>
@ -170,7 +187,7 @@ export default function Header ({ sub }) {
<Navbar className='pb-0 pb-md-1'> <Navbar className='pb-0 pb-md-1'>
<Nav <Nav
className={styles.navbarNav} className={styles.navbarNav}
activeKey={path} activeKey={topNavKey}
> >
<div className='d-flex'> <div className='d-flex'>
<Link href='/' passHref> <Link href='/' passHref>
@ -194,7 +211,7 @@ export default function Header ({ sub }) {
<Navbar className='pt-0 pb-1 d-md-none'> <Navbar className='pt-0 pb-1 d-md-none'>
<Nav <Nav
className={`${styles.navbarNav} justify-content-around`} className={`${styles.navbarNav} justify-content-around`}
activeKey={path} activeKey={topNavKey}
> >
<NavItems /> <NavItems />
</Nav> </Nav>

View File

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

View File

@ -21,7 +21,7 @@ export default function Invite ({ invite, active }) {
<CopyInput <CopyInput
groupClassName='mb-1' groupClassName='mb-1'
size='sm' type='text' 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}> <div className={styles.other}>
<span>{invite.gift} sat gift</span> <span>{invite.gift} sat gift</span>

View File

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

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

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

View File

@ -7,98 +7,20 @@ import Countdown from './countdown'
import { NOFOLLOW_LIMIT } from '../lib/constants' import { NOFOLLOW_LIMIT } from '../lib/constants'
import Pin from '../svgs/pushpin-fill.svg' import Pin from '../svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace' import reactStringReplace from 'react-string-replace'
import { formatSats } from '../lib/format' import Toc from './table-of-contents'
import * as Yup from 'yup' import PollIcon from '../svgs/bar-chart-horizontal-fill.svg'
import Briefcase from '../svgs/briefcase-4-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 reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
return <mark key={`mark-${match}`}>{match}</mark> 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 }) { function FwdUser ({ user }) {
return ( return (
<div className={styles.other}> <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 mine = item.mine
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
const [canEdit, setCanEdit] = const [canEdit, setCanEdit] =
useState(mine && (Date.now() < editThreshold)) useState(mine && (Date.now() < editThreshold))
const [wrap, setWrap] = useState(false) const [wrap, setWrap] = useState(false)
const titleRef = useRef() const titleRef = useRef()
const me = useMe()
const [hasNewComments, setHasNewComments] = useState(false)
useEffect(() => { useEffect(() => {
setWrap( setWrap(
@ -124,6 +48,11 @@ export default function Item ({ item, rank, showFwdUser, children }) {
titleRef.current.clientHeight) titleRef.current.clientHeight)
}, []) }, [])
useEffect(() => {
// if we are showing toc, then this is a full item
setHasNewComments(!toc && newComments(item))
}, [item])
return ( return (
<> <>
{rank {rank
@ -133,12 +62,15 @@ export default function Item ({ item, rank, showFwdUser, children }) {
</div>) </div>)
: <div />} : <div />}
<div className={styles.item}> <div className={styles.item}>
{item.position ? <Pin width={24} height={24} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />} {item.position
? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLike ? <Flag width={24} height={24} className={`${styles.dontLike}`} /> : <UpVote item={item} className={styles.upvote} />}
<div className={styles.hunk}> <div className={styles.hunk}>
<div className={`${styles.main} flex-wrap ${wrap ? 'd-inline' : ''}`}> <div className={`${styles.main} flex-wrap ${wrap ? 'd-inline' : ''}`}>
<Link href={`/items/${item.id}`} passHref> <Link href={`/items/${item.id}`} passHref>
<a ref={titleRef} className={`${styles.title} text-reset mr-2`}> <a ref={titleRef} className={`${styles.title} text-reset mr-2`}>
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title} {item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
{item.pollCost && <span> <PollIcon className='fill-grey vertical-align-baseline' height={14} width={14} /></span>}
</a> </a>
</Link> </Link>
{item.url && {item.url &&
@ -155,7 +87,7 @@ export default function Item ({ item, rank, showFwdUser, children }) {
<div className={`${styles.other}`}> <div className={`${styles.other}`}>
{!item.position && {!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> <span> \ </span>
</>} </>}
{item.boost > 0 && {item.boost > 0 &&
@ -164,7 +96,10 @@ export default function Item ({ item, rank, showFwdUser, children }) {
<span> \ </span> <span> \ </span>
</>} </>}
<Link href={`/items/${item.id}`} passHref> <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> </Link>
<span> \ </span> <span> \ </span>
<span> <span>
@ -175,6 +110,9 @@ export default function Item ({ item, rank, showFwdUser, children }) {
<Link href={`/items/${item.id}`} passHref> <Link href={`/items/${item.id}`} passHref>
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a> <a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link> </Link>
{me && !item.meSats && !item.position && !item.meDontLike && !item.mine && <DontLikeThis id={item.id} />}
{(item.outlawed && <Link href='/outlawed'><a>{' '}<Badge className={styles.newComment} variant={null}>OUTLAWED</Badge></a></Link>) ||
(item.freebie && !item.mine && (me?.greeterMode) && <Link href='/freebie'><a>{' '}<Badge className={styles.newComment} variant={null}>FREEBIE</Badge></a></Link>)}
{item.prior && {item.prior &&
<> <>
<span> \ </span> <span> \ </span>
@ -202,6 +140,7 @@ export default function Item ({ item, rank, showFwdUser, children }) {
</div> </div>
{showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />} {showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />}
</div> </div>
{toc && <Toc text={item.text} />}
</div> </div>
{children && ( {children && (
<div className={styles.children}> <div className={styles.children}>

View File

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

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

@ -0,0 +1,47 @@
import { useRouter } from 'next/router'
import React from 'react'
import { ignoreClick } from '../lib/clicks'
import Comment from './comment'
import Item from './item'
import ItemJob from './item-job'
import { ItemsSkeleton } from './items'
import styles from './items.module.css'
import MoreFooter from './more-footer'
export default function MixedItems ({ rank, items, cursor, fetchMore }) {
const router = useRouter()
return (
<>
<div className={styles.grid}>
{items.map((item, i) => (
<React.Fragment key={item.id}>
{item.parentId
? (
<><div />
<div
className='pb-1 mb-1 clickToContext' onClick={e => {
if (ignoreClick(e)) {
return
}
router.push({
pathname: '/items/[id]',
query: { id: item.root.id, commentId: item.id }
}, `/items/${item.root.id}`)
}}
>
<Comment item={item} noReply includeParent clickToContext />
</div>
</>)
: (item.isJob
? <ItemJob item={item} rank={rank && i + 1} />
: <Item item={item} rank={rank && i + 1} />)}
</React.Fragment>
))}
</div>
<MoreFooter
cursor={cursor} fetchMore={fetchMore}
Skeleton={() => <ItemsSkeleton rank={rank} startRank={items.length} />}
/>
</>
)
}

View File

@ -1,5 +1,6 @@
import { useQuery } from '@apollo/client' import { 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 styles from './items.module.css'
import { ITEMS } from '../fragments/items' import { ITEMS } from '../fragments/items'
import MoreFooter from './more-footer' 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]} />} {pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />}
{item.parentId {item.parentId
? <><div /><div className='pb-3'><Comment item={item} noReply includeParent /></div></> ? <><div /><div className='pb-3'><Comment item={item} noReply includeParent /></div></>
: (item.maxBid : (item.isJob
? <ItemJob item={item} rank={rank && i + 1} /> ? <ItemJob item={item} rank={rank && i + 1} />
: <Item item={item} rank={rank && i + 1} />)} : (item.title
? <Item item={item} rank={rank && i + 1} />
: (
<div className='pb-2'>
<Comment item={item} noReply includeParent clickToContext />
</div>)))}
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>
@ -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) const items = new Array(21).fill(null)
return ( return (

View File

@ -1,6 +1,6 @@
import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form' import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form'
import TextareaAutosize from 'react-textarea-autosize' 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 * as Yup from 'yup'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Info from './info' import Info from './info'
@ -10,6 +10,9 @@ import { useLazyQuery, gql, useMutation } from '@apollo/client'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import Link from 'next/link' import Link from 'next/link'
import { usePrice } from './price' 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) { Yup.addMethod(Yup.string, 'or', function (schemas, msg) {
return this.test({ return this.test({
@ -33,7 +36,7 @@ function satsMin2Mo (minute) {
function PriceHint ({ monthly }) { function PriceHint ({ monthly }) {
const price = usePrice() const price = usePrice()
if (!price) { if (!price || !monthly) {
return null return null
} }
const fixed = (n, f) => Number.parseFloat(n).toFixed(f) const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
@ -46,18 +49,13 @@ function PriceHint ({ monthly }) {
export default function JobForm ({ item, sub }) { export default function JobForm ({ item, sub }) {
const storageKeyPrefix = item ? undefined : `${sub.name}-job` const storageKeyPrefix = item ? undefined : `${sub.name}-job`
const router = useRouter() const router = useRouter()
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || sub.baseCost)) const [logoId, setLogoId] = useState(item?.uploadId)
const [getAuctionPosition, { data }] = useLazyQuery(gql`
query AuctionPosition($id: ID, $bid: Int!) {
auctionPosition(sub: "${sub.name}", id: $id, bid: $bid)
}`,
{ fetchPolicy: 'network-only' })
const [upsertJob] = useMutation(gql` const [upsertJob] = useMutation(gql`
mutation upsertJob($id: ID, $title: String!, $company: String!, $location: String, mutation upsertJob($id: ID, $title: String!, $company: String!, $location: String,
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String) { $remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int) {
upsertJob(sub: "${sub.name}", id: $id, title: $title, company: $company, upsertJob(sub: "${sub.name}", id: $id, title: $title, company: $company,
location: $location, remote: $remote, text: $text, location: $location, remote: $remote, text: $text,
url: $url, maxBid: $maxBid, status: $status) { url: $url, maxBid: $maxBid, status: $status, logo: $logo) {
id id
} }
}` }`
@ -69,9 +67,9 @@ export default function JobForm ({ item, sub }) {
text: Yup.string().required('required').trim(), text: Yup.string().required('required').trim(),
url: Yup.string() url: Yup.string()
.or([Yup.string().email(), Yup.string().url()], 'invalid url or email') .or([Yup.string().email(), Yup.string().url()], 'invalid url or email')
.required('Required'), .required('required'),
maxBid: Yup.number('must be number') maxBid: Yup.number().typeError('must be a number')
.integer('must be whole').min(sub.baseCost, `must be at least ${sub.baseCost}`) .integer('must be whole').min(0, 'must be positive')
.required('required'), .required('required'),
location: Yup.string().test( location: Yup.string().test(
'no-remote', 'no-remote',
@ -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 ( return (
<> <>
<Form <Form
@ -102,7 +92,7 @@ export default function JobForm ({ item, sub }) {
remote: item?.remote || false, remote: item?.remote || false,
text: item?.text || '', text: item?.text || '',
url: item?.url || '', url: item?.url || '',
maxBid: item?.maxBid || sub.baseCost, maxBid: item?.maxBid || 0,
stop: false, stop: false,
start: false start: false
}} }}
@ -122,6 +112,7 @@ export default function JobForm ({ item, sub }) {
sub: sub.name, sub: sub.name,
maxBid: Number(maxBid), maxBid: Number(maxBid),
status, status,
logo: Number(logoId),
...values ...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 <Input
label='job title' label='job title'
name='title' name='title'
required required
autoFocus autoFocus
clear
/> />
<Input <Input
label='company' label='company'
name='company' name='company'
required required
clear
/> />
<BForm.Row className='mr-0'> <BForm.Row className='mr-0'>
<Col> <Col>
<Input <Input
label='location' label='location'
name='location' name='location'
clear
/> />
</Col> </Col>
<Checkbox <Checkbox
@ -171,37 +174,68 @@ export default function JobForm ({ item, sub }) {
label={<>how to apply <small className='text-muted ml-2'>url or email address</small></>} label={<>how to apply <small className='text-muted ml-2'>url or email address</small></>}
name='url' name='url'
required required
clear
/> />
<PromoteJob item={item} sub={sub} storageKeyPrefix={storageKeyPrefix} />
{item && <StatusControl item={item} />}
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
</Form>
</>
)
}
function PromoteJob ({ item, sub, storageKeyPrefix }) {
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0))
const [getAuctionPosition, { data }] = useLazyQuery(gql`
query AuctionPosition($id: ID, $bid: Int!) {
auctionPosition(sub: "${sub.name}", id: $id, bid: $bid)
}`,
{ fetchPolicy: 'network-only' })
const position = data?.auctionPosition
useEffect(() => {
const initialMaxBid = Number(item?.maxBid || localStorage.getItem(storageKeyPrefix + '-maxBid')) || 0
getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
setMonthly(satsMin2Mo(initialMaxBid))
}, [])
return (
<AccordianItem
show={item?.maxBid > 0}
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>}
body={
<>
<Input <Input
label={ label={
<div className='d-flex align-items-center'>bid <div className='d-flex align-items-center'>bid
<Info> <Info>
<ol className='font-weight-bold'> <ol className='font-weight-bold'>
<li>The higher your bid the higher your job will rank</li> <li>The higher your bid the higher your job will rank</li>
<li>The minimum bid is {sub.baseCost} sats/min</li> <li>You can increase, decrease, or remove your bid at anytime</li>
<li>You can increase or decrease your bid, and edit or stop your job at anytime</li> <li>You can edit or stop your job at anytime</li>
<li>Your job will be hidden if your wallet runs out of sats and can be unhidden by filling your wallet again</li> <li>If you run out of sats, your job will stop being promoted until you fill your wallet again</li>
</ol> </ol>
</Info> </Info>
<small className='text-muted ml-2'>optional</small>
</div> </div>
} }
name='maxBid' name='maxBid'
onChange={async (formik, e) => { onChange={async (formik, e) => {
if (e.target.value >= sub.baseCost && e.target.value <= 100000000) { if (e.target.value >= 0 && e.target.value <= 100000000) {
setMonthly(satsMin2Mo(e.target.value)) setMonthly(satsMin2Mo(e.target.value))
getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } }) getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } })
} else { } else {
setMonthly(satsMin2Mo(sub.baseCost)) setMonthly(satsMin2Mo(0))
} }
}} }}
append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>} append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>}
hint={<PriceHint monthly={monthly} />} hint={<PriceHint monthly={monthly} />}
storageKeyPrefix={storageKeyPrefix}
/> />
<><div className='font-weight-bold text-muted'>This bid puts your job in position: {position}</div></> <><div className='font-weight-bold text-muted'>This bid puts your job in position: {position}</div></>
{item && <StatusControl item={item} />}
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
</Form>
</> </>
}
/>
) )
} }
@ -225,7 +259,7 @@ function StatusControl ({ item }) {
</> </>
) )
} }
} else { } else if (item.status === 'STOPPED') {
StatusComp = () => { StatusComp = () => {
return ( return (
<AccordianItem <AccordianItem
@ -242,12 +276,13 @@ function StatusControl ({ item }) {
} }
return ( return (
<div className='my-2'> <div className='my-3 border border-3 rounded'>
<div className='p-3'>
<BootstrapForm.Label>job control</BootstrapForm.Label>
{item.status === 'NOSATS' && {item.status === 'NOSATS' &&
<div className='text-danger font-weight-bold my-1'> <Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' passHref><a className='text-reset text-underline'>fund your wallet</a></Link> or reduce bid to continue promoting your job</Alert>}
you have no sats! <Link href='/wallet?type=fund' passHref><a className='text-reset text-underline'>fund your wallet</a></Link> to resume your job
</div>}
<StatusComp /> <StatusComp />
</div> </div>
</div>
) )
} }

View File

@ -2,13 +2,13 @@ import { Form, Input, SubmitButton } from '../components/form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import * as Yup from 'yup' import * as Yup from 'yup'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import ActionTooltip from '../components/action-tooltip'
import Countdown from './countdown' import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
import { ITEM_FIELDS } from '../fragments/items' import { ITEM_FIELDS } from '../fragments/items'
import Item from './item' import Item from './item'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import { MAX_TITLE_LENGTH } from '../lib/constants' import { MAX_TITLE_LENGTH } from '../lib/constants'
import FeeButton, { EditFeeButton } from './fee-button'
// eslint-disable-next-line // 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 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={{ initial={{
title: item?.title || '', title: item?.title || '',
url: item?.url || '', url: item?.url || '',
...AdvPostInitial ...AdvPostInitial({ forward: item?.fwdUser?.name })
}} }}
schema={LinkSchema} schema={LinkSchema}
onSubmit={async ({ boost, title, ...values }) => { onSubmit={async ({ boost, title, ...values }) => {
@ -78,12 +78,14 @@ export function LinkForm ({ item, editThreshold }) {
name='title' name='title'
overrideValue={data?.pageTitle} overrideValue={data?.pageTitle}
required required
clear
/> />
<Input <Input
label='url' label='url'
name='url' name='url'
required required
autoFocus autoFocus
clear
hint={editThreshold hint={editThreshold
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div> ? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
: null} : null}
@ -98,10 +100,18 @@ export function LinkForm ({ item, editThreshold }) {
}) })
}} }}
/> />
{!item && <AdvPostForm />} <AdvPostForm edit={!!item} />
<ActionTooltip> <div className='mt-3'>
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton> {item
</ActionTooltip> ? <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 && {dupesData?.dupes?.length > 0 &&
<div className='mt-3'> <div className='mt-3'>
<AccordianItem <AccordianItem

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment' 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 { NOTIFICATIONS } from '../fragments/notifications'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import MoreFooter from './more-footer' 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)' }} /> <HandCoin className='align-self-center fill-boost mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} />
<div className='ml-2'> <div className='ml-2'>
<div className='font-weight-bold text-boost'> <div className='font-weight-bold text-boost'>
you stacked {n.earnedSats} sats <small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small> you stacked {n.earnedSats} sats in rewards<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</div> </div>
{n.sources &&
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
{n.sources.posts > 0 && <span>{n.sources.posts} sats for top posts</span>}
{n.sources.comments > 0 && <span>{n.sources.posts > 0 && ' \\ '}{n.sources.comments} sats for top comments</span>}
{n.sources.tips > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0) && ' \\ '}{n.sources.tips} sats for tipping top content early</span>}
</div>}
<div className='pb-1' style={{ lineHeight: '140%' }}> <div className='pb-1' style={{ lineHeight: '140%' }}>
SN distributes the sats it earns back to its best users daily. These sats come from <Link href='/~jobs' passHref><a>jobs</a></Link>, boost, and posting fees. SN distributes the sats it earns back to its best users daily. These sats come from <Link href='/~jobs' passHref><a>jobs</a></Link>, boost, and posting fees.
</div> </div>
@ -98,13 +105,15 @@ function Notification ({ n }) {
you were mentioned in you were mentioned in
</small>} </small>}
{n.__typename === 'JobChanged' && {n.__typename === 'JobChanged' &&
<small className={`font-weight-bold text-${n.item.status === 'NOSATS' ? 'danger' : 'success'} ml-1`}> <small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
{n.item.status === 'NOSATS' {n.item.status === 'ACTIVE'
? 'your job ran out of sats' ? 'your job is active again'
: 'your job is active again'} : (n.item.status === 'NOSATS'
? 'your job promotion ran out of sats'
: 'your job has been stopped')}
</small>} </small>}
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}> <div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}>
{n.item.maxBid {n.item.isJob
? <ItemJob item={n.item} /> ? <ItemJob item={n.item} />
: n.item.title : n.item.title
? <Item item={n.item} /> ? <Item item={n.item} />

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

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

99
components/poll.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -4,11 +4,11 @@ import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css' import styles from './reply.module.css'
import { COMMENTS } from '../fragments/comments' import { COMMENTS } from '../fragments/comments'
import { useMe } from './me' import { useMe } from './me'
import ActionTooltip from './action-tooltip'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Info from './info'
import Link from 'next/link' import Link from 'next/link'
import FeeButton from './fee-button'
import { commentsViewedAfterComment } from '../lib/new-comments'
export const CommentSchema = Yup.object({ export const CommentSchema = Yup.object({
text: Yup.string().required('required').trim() 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 [reply, setReply] = useState(replyOpen)
const me = useMe() const me = useMe()
const parentId = item.id
useEffect(() => { useEffect(() => {
setReply(replyOpen || !!localStorage.getItem('reply-' + parentId + '-' + 'text')) setReply(replyOpen || !!localStorage.getItem('reply-' + parentId + '-' + 'text'))
@ -52,21 +53,32 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) {
fragmentName: 'CommentsRecursive' fragmentName: 'CommentsRecursive'
}) })
return [newCommentRef, ...existingCommentRefs] 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 ( return (
<div> <div>
{replyOpen {replyOpen
@ -102,16 +114,13 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) {
required required
hint={me?.freeComments ? <span className='text-success'>{me.freeComments} free comments left</span> : null} hint={me?.freeComments ? <span className='text-success'>{me.freeComments} free comments left</span> : null}
/> />
<div className='d-flex align-items-center mt-1'> {reply &&
<ActionTooltip overlayText={`${cost} sats`}> <div className='mt-1'>
<SubmitButton variant='secondary'>reply{cost > 1 && <small> {cost} sats</small>}</SubmitButton> <FeeButton
</ActionTooltip> baseFee={1} parentId={parentId} text='reply'
{cost > 1 && ( ChildButton={SubmitButton} variant='secondary' alwaysShow
<Info> />
<div className='font-weight-bold'>Multiple replies on the same level get pricier, but we still love your thoughts!</div> </div>}
</Info>
)}
</div>
</Form> </Form>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
import { Button, Container } from 'react-bootstrap' import { Button, Container } from 'react-bootstrap'
import styles from './search.module.css' import styles from './search.module.css'
import SearchIcon from '../svgs/search-fill.svg' import SearchIcon from '../svgs/search-line.svg'
import CloseIcon from '../svgs/close-line.svg' import CloseIcon from '../svgs/close-line.svg'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Form, Input, SubmitButton } from './form' import { Form, Input, SubmitButton } from './form'
@ -50,7 +50,8 @@ export default function Search ({ sub }) {
required required
autoFocus={showSearch && !atBottom} autoFocus={showSearch && !atBottom}
groupClassName='mr-3 mb-0 flex-grow-1' groupClassName='mr-3 mb-0 flex-grow-1'
className='w-100' className='flex-grow-1'
clear
onChange={async (formik, e) => { onChange={async (formik, e) => {
setSearching(true) setSearching(true)
setQ(e.target.value?.trim()) setQ(e.target.value?.trim())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,9 @@ export const ME = gql`
noteDeposits noteDeposits
noteInvites noteInvites
noteJobIndicator noteJobIndicator
hideInvoiceDesc
wildWestMode
greeterMode
lastCheckedJobs lastCheckedJobs
} }
}` }`
@ -48,13 +51,15 @@ export const ME_SSR = gql`
noteDeposits noteDeposits
noteInvites noteInvites
noteJobIndicator noteJobIndicator
hideInvoiceDesc
wildWestMode
greeterMode
lastCheckedJobs lastCheckedJobs
} }
}` }`
export const SETTINGS = gql` export const SETTINGS_FIELDS = gql`
{ fragment SettingsFields on User {
settings {
tipDefault tipDefault
noteItemSats noteItemSats
noteEarning noteEarning
@ -63,15 +68,42 @@ export const SETTINGS = gql`
noteDeposits noteDeposits
noteInvites noteInvites
noteJobIndicator noteJobIndicator
hideInvoiceDesc
wildWestMode
greeterMode
authMethods { authMethods {
lightning lightning
email email
twitter twitter
github 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 = export const NAME_QUERY =
gql` gql`
query nameAvailable($name: String!) { 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` export const USER_FIELDS = gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
fragment UserFields on User { fragment UserFields on User {
@ -153,6 +193,11 @@ export const USER_WITH_POSTS = gql`
cursor cursor
items { items {
...ItemFields ...ItemFields
position
}
pins {
...ItemFields
position
} }
} }
}` }`

View File

@ -52,6 +52,45 @@ export default function getApolloClient () {
} }
} }
}, },
outlawedItems: {
keyArgs: [],
merge (existing, incoming) {
if (isFirstPage(incoming.cursor, existing?.items)) {
return incoming
}
return {
cursor: incoming.cursor,
items: [...(existing?.items || []), ...incoming.items]
}
}
},
borderlandItems: {
keyArgs: [],
merge (existing, incoming) {
if (isFirstPage(incoming.cursor, existing?.items)) {
return incoming
}
return {
cursor: incoming.cursor,
items: [...(existing?.items || []), ...incoming.items]
}
}
},
freebieItems: {
keyArgs: [],
merge (existing, incoming) {
if (isFirstPage(incoming.cursor, existing?.items)) {
return incoming
}
return {
cursor: incoming.cursor,
items: [...(existing?.items || []), ...incoming.items]
}
}
},
search: { search: {
keyArgs: ['q'], keyArgs: ['q'],
merge (existing, incoming) { merge (existing, incoming) {
@ -79,7 +118,7 @@ export default function getApolloClient () {
} }
}, },
notifications: { notifications: {
keyArgs: false, keyArgs: ['inc'],
merge (existing, incoming) { merge (existing, incoming) {
if (isFirstPage(incoming.cursor, existing?.notifications)) { if (isFirstPage(incoming.cursor, existing?.notifications)) {
return incoming return incoming

View File

@ -1,5 +1,5 @@
export const NOFOLLOW_LIMIT = 100 export const NOFOLLOW_LIMIT = 1000
export const BOOST_MIN = 1000 export const BOOST_MIN = 5000
export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024 export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024
export const IMAGE_PIXELS_MAX = 35000000 export const IMAGE_PIXELS_MAX = 35000000
export const UPLOAD_TYPES_ALLOW = [ export const UPLOAD_TYPES_ALLOW = [
@ -11,3 +11,8 @@ export const UPLOAD_TYPES_ALLOW = [
] ]
export const COMMENT_DEPTH_LIMIT = 10 export const COMMENT_DEPTH_LIMIT = 10
export const MAX_TITLE_LENGTH = 80 export const MAX_TITLE_LENGTH = 80
export const MAX_POLL_CHOICE_LENGTH = 30
export const ITEM_SPAM_INTERVAL = '10m'
export const MAX_POLL_NUM_CHOICES = 10
export const ITEM_FILTER_THRESHOLD = 1.2
export const DONT_LIKE_THIS_COST = 1

View File

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

19
lib/md.js Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import '../styles/globals.scss' import '../styles/globals.scss'
import { ApolloProvider, gql } from '@apollo/client' import { ApolloProvider, gql, useQuery } from '@apollo/client'
import { Provider } from 'next-auth/client' import { Provider } from 'next-auth/client'
import { FundErrorModal, FundErrorProvider } from '../components/fund-error' import { FundErrorModal, FundErrorProvider } from '../components/fund-error'
import { MeProvider } from '../components/me' import { MeProvider } from '../components/me'
@ -10,26 +10,63 @@ import getApolloClient from '../lib/apollo'
import NextNProgress from 'nextjs-progressbar' import NextNProgress from 'nextjs-progressbar'
import { PriceProvider } from '../components/price' import { PriceProvider } from '../components/price'
import Head from 'next/head' 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 } }) { function MyApp ({ Component, pageProps: { session, ...props } }) {
const client = getApolloClient() 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 If we are on the client, we populate the apollo cache with the
ssr data ssr data
*/ */
if (typeof window !== 'undefined') { const { apollo, data, me, price } = props
const { apollo, data } = props if (typeof window !== 'undefined' && apollo && data) {
if (apollo) {
client.writeQuery({ client.writeQuery({
query: gql`${apollo.query}`, query: gql`${apollo.query}`,
data: data, data: data,
variables: apollo.variables variables: apollo.variables
}) })
} }
}
const { me, price } = props
return ( return (
<> <>
@ -54,7 +91,9 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
<FundErrorModal /> <FundErrorModal />
<ItemActProvider> <ItemActProvider>
<ItemActModal /> <ItemActModal />
<Component {...props} /> {data || !apollo?.query
? <Component {...props} />
: <CSRWrapper Component={Component} {...props} />}
</ItemActProvider> </ItemActProvider>
</FundErrorProvider> </FundErrorProvider>
</LightningProvider> </LightningProvider>

View File

@ -10,7 +10,7 @@ export default async ({ query: { username } }, res) => {
return res.status(200).json({ 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 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` 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 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 tag: 'payRequest' // Type of LNURL
}) })

View File

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

32
pages/borderland.js Normal file
View File

@ -0,0 +1,32 @@
import Layout from '../components/layout'
import { ItemsSkeleton } from '../components/items'
import { getGetServerSideProps } from '../api/ssrApollo'
import { BORDERLAND_ITEMS } from '../fragments/items'
import { useQuery } from '@apollo/client'
import MixedItems from '../components/items-mixed'
export const getServerSideProps = getGetServerSideProps(BORDERLAND_ITEMS)
export default function Index ({ data: { borderlandItems: { items, cursor } } }) {
return (
<Layout>
<Items
items={items} cursor={cursor}
/>
</Layout>
)
}
function Items ({ rank, items, cursor }) {
const { data, fetchMore } = useQuery(BORDERLAND_ITEMS)
if (!data && !items) {
return <ItemsSkeleton rank={rank} />
}
if (data) {
({ borderlandItems: { items, cursor } } = data)
}
return <MixedItems items={items} cursor={cursor} rank={rank} fetchMore={fetchMore} />
}

32
pages/freebie.js Normal file
View File

@ -0,0 +1,32 @@
import Layout from '../components/layout'
import { ItemsSkeleton } from '../components/items'
import { getGetServerSideProps } from '../api/ssrApollo'
import { FREEBIE_ITEMS } from '../fragments/items'
import { useQuery } from '@apollo/client'
import MixedItems from '../components/items-mixed'
export const getServerSideProps = getGetServerSideProps(FREEBIE_ITEMS)
export default function Index ({ data: { freebieItems: { items, cursor } } }) {
return (
<Layout>
<Items
items={items} cursor={cursor}
/>
</Layout>
)
}
function Items ({ rank, items, cursor }) {
const { data, fetchMore } = useQuery(FREEBIE_ITEMS)
if (!data && !items) {
return <ItemsSkeleton rank={rank} />
}
if (data) {
({ freebieItems: { items, cursor } } = data)
}
return <MixedItems items={items} cursor={cursor} rank={rank} fetchMore={fetchMore} />
}

View File

@ -4,6 +4,7 @@ import { DiscussionForm } from '../../../components/discussion-form'
import { LinkForm } from '../../../components/link-form' import { LinkForm } from '../../../components/link-form'
import LayoutCenter from '../../../components/layout-center' import LayoutCenter from '../../../components/layout-center'
import JobForm from '../../../components/job-form' import JobForm from '../../../components/job-form'
import { PollForm } from '../../../components/poll-form'
export const getServerSideProps = getGetServerSideProps(ITEM, null, export const getServerSideProps = getGetServerSideProps(ITEM, null,
data => !data.item) data => !data.item)
@ -13,11 +14,13 @@ export default function PostEdit ({ data: { item } }) {
return ( return (
<LayoutCenter sub={item.sub?.name}> <LayoutCenter sub={item.sub?.name}>
{item.maxBid {item.isJob
? <JobForm item={item} sub={item.sub} /> ? <JobForm item={item} sub={item.sub} />
: (item.url : (item.url
? <LinkForm item={item} editThreshold={editThreshold} /> ? <LinkForm item={item} editThreshold={editThreshold} adv />
: <DiscussionForm item={item} editThreshold={editThreshold} />)} : (item.pollCost
? <PollForm item={item} editThreshold={editThreshold} />
: <DiscussionForm item={item} editThreshold={editThreshold} adv />))}
</LayoutCenter> </LayoutCenter>
) )
} }

View File

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

32
pages/outlawed.js Normal file
View File

@ -0,0 +1,32 @@
import Layout from '../components/layout'
import { ItemsSkeleton } from '../components/items'
import { getGetServerSideProps } from '../api/ssrApollo'
import { OUTLAWED_ITEMS } from '../fragments/items'
import { useQuery } from '@apollo/client'
import MixedItems from '../components/items-mixed'
export const getServerSideProps = getGetServerSideProps(OUTLAWED_ITEMS)
export default function Index ({ data: { outlawedItems: { items, cursor } } }) {
return (
<Layout>
<Items
items={items} cursor={cursor}
/>
</Layout>
)
}
function Items ({ rank, items, cursor }) {
const { data, fetchMore } = useQuery(OUTLAWED_ITEMS)
if (!data && !items) {
return <ItemsSkeleton rank={rank} />
}
if (data) {
({ outlawedItems: { items, cursor } } = data)
}
return <MixedItems items={items} cursor={cursor} rank={rank} fetchMore={fetchMore} />
}

View File

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

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

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

View File

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

View File

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

View File

@ -9,8 +9,9 @@ import LoginButton from '../components/login-button'
import { signIn } from 'next-auth/client' import { signIn } from 'next-auth/client'
import ModalButton from '../components/modal-button' import ModalButton from '../components/modal-button'
import { LightningAuth } from '../components/lightning-auth' import { LightningAuth } from '../components/lightning-auth'
import { SETTINGS } from '../fragments/users' import { SETTINGS, SET_SETTINGS } from '../fragments/users'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import Info from '../components/info'
export const getServerSideProps = getGetServerSideProps(SETTINGS) export const getServerSideProps = getGetServerSideProps(SETTINGS)
@ -27,16 +28,18 @@ export const WarningSchema = Yup.object({
export default function Settings ({ data: { settings } }) { export default function Settings ({ data: { settings } }) {
const [success, setSuccess] = useState() const [success, setSuccess] = useState()
const [setSettings] = useMutation( const [setSettings] = useMutation(SET_SETTINGS, {
gql` update (cache, { data: { setSettings } }) {
mutation setSettings($tipDefault: Int!, $noteItemSats: Boolean!, $noteEarning: Boolean!, cache.modify({
$noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!, id: 'ROOT_QUERY',
$noteInvites: Boolean!, $noteJobIndicator: Boolean!) { fields: {
setSettings(tipDefault: $tipDefault, noteItemSats: $noteItemSats, settings () {
noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants, return setSettings
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites, }
noteJobIndicator: $noteJobIndicator) }
}` })
}
}
) )
const { data } = useQuery(SETTINGS) const { data } = useQuery(SETTINGS)
@ -57,7 +60,10 @@ export default function Settings ({ data: { settings } }) {
noteMentions: settings?.noteMentions, noteMentions: settings?.noteMentions,
noteDeposits: settings?.noteDeposits, noteDeposits: settings?.noteDeposits,
noteInvites: settings?.noteInvites, noteInvites: settings?.noteInvites,
noteJobIndicator: settings?.noteJobIndicator noteJobIndicator: settings?.noteJobIndicator,
hideInvoiceDesc: settings?.hideInvoiceDesc,
wildWestMode: settings?.wildWestMode,
greeterMode: settings?.greeterMode
}} }}
schema={SettingsSchema} schema={SettingsSchema}
onSubmit={async ({ tipDefault, ...values }) => { onSubmit={async ({ tipDefault, ...values }) => {
@ -108,6 +114,54 @@ export default function Settings ({ data: { settings } }) {
label='there is a new job' label='there is a new job'
name='noteJobIndicator' 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'> <div className='d-flex'>
<SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton> <SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton>
</div> </div>
@ -241,6 +295,7 @@ function AuthMethods ({ methods }) {
placeholder={methods.email} placeholder={methods.email}
groupClassName='mb-0' groupClassName='mb-0'
readOnly readOnly
noForm
/> />
<Button <Button
className='ml-2' variant='secondary' onClick={ className='ml-2' variant='secondary' onClick={

View File

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

View File

@ -153,7 +153,8 @@ export function WithdrawlForm () {
try { try {
const provider = await requestProvider() const provider = await requestProvider()
const { paymentRequest: invoice } = await provider.makeInvoice({ 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 } }) const { data } = await createWithdrawl({ variables: { invoice, maxFee: MAX_FEE_DEFAULT } })
router.push(`/withdrawals/${data.createWithdrawl.id}`) router.push(`/withdrawals/${data.createWithdrawl.id}`)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,23 @@
-- create poll
-- charges us to create poll
-- adds poll options to poll
CREATE OR REPLACE FUNCTION create_poll(title TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER, options TEXT[])
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := create_item(title, null, null, boost, null, user_id);
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
FOREACH option IN ARRAY options LOOP
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
END LOOP;
RETURN item;
END;
$$;

View File

@ -0,0 +1,112 @@
CREATE OR REPLACE FUNCTION create_poll(title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER, options TEXT[])
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := create_item(title, null, text, boost, null, user_id);
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
FOREACH option IN ARRAY options LOOP
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
END LOOP;
RETURN item;
END;
$$;
-- create poll vote
-- if user hasn't already voted
-- charges user item.pollCost
-- adds POLL to ItemAct
-- adds PollVote
CREATE OR REPLACE FUNCTION poll_vote(option_id INTEGER, user_id INTEGER)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option "PollOption";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT * INTO option FROM "PollOption" where id = option_id;
IF option IS NULL THEN
RAISE EXCEPTION 'INVALID_POLL_OPTION';
END IF;
SELECT * INTO item FROM "Item" where id = option."itemId";
IF item IS NULL THEN
RAISE EXCEPTION 'POLL_DOES_NOT_EXIST';
END IF;
IF item."userId" = user_id THEN
RAISE EXCEPTION 'POLL_OWNER_CANT_VOTE';
END IF;
IF EXISTS (SELECT 1 FROM "PollVote" WHERE "itemId" = item.id AND "userId" = user_id) THEN
RAISE EXCEPTION 'POLL_VOTE_ALREADY_EXISTS';
END IF;
PERFORM item_act(item.id, user_id, 'POLL', item."pollCost");
INSERT INTO "PollVote" (created_at, updated_at, "itemId", "pollOptionId", "userId")
VALUES (now_utc(), now_utc(), item.id, option_id, user_id);
RETURN item;
END;
$$;
CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
user_sats INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT (msats / 1000) INTO user_sats FROM users WHERE id = user_id;
IF act_sats > user_sats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
-- deduct sats from actor
UPDATE users SET msats = msats - (act_sats * 1000) WHERE id = user_id;
IF act = 'BOOST' OR act = 'POLL' THEN
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_sats, item_id, user_id, act, now_utc(), now_utc());
ELSE
-- add sats to actee's balance and stacked count
UPDATE users
SET msats = msats + (act_sats * 1000), "stackedMsats" = "stackedMsats" + (act_sats * 1000)
WHERE id = (SELECT COALESCE("fwdUserId", "userId") FROM "Item" WHERE id = item_id);
-- if they have already voted, this is a tip
IF EXISTS (SELECT 1 FROM "ItemAct" WHERE "itemId" = item_id AND "userId" = user_id AND "ItemAct".act = 'VOTE') THEN
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc());
ELSE
-- else this is a vote with a possible extra tip
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
VALUES (1, item_id, user_id, 'VOTE', now_utc(), now_utc());
act_sats := act_sats - 1;
-- if we have sats left after vote, leave them as a tip
IF act_sats > 0 THEN
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc());
END IF;
RETURN 1;
END IF;
END IF;
RETURN 0;
END;
$$;

View File

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

View File

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

View File

@ -0,0 +1,83 @@
CREATE OR REPLACE FUNCTION item_spam(parent_id INTEGER, user_id INTEGER, within INTERVAL)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
repeats INTEGER;
self_replies INTEGER;
BEGIN
SELECT count(*) INTO repeats
FROM "Item"
WHERE (parent_id IS NULL AND "parentId" IS NULL OR "parentId" = parent_id)
AND "userId" = user_id
AND created_at > now_utc() - within;
IF parent_id IS NULL THEN
RETURN repeats;
END IF;
WITH RECURSIVE base AS (
SELECT "Item".id, "Item"."parentId", "Item"."userId"
FROM "Item"
WHERE id = parent_id AND "userId" = user_id AND created_at > now_utc() - within
UNION ALL
SELECT "Item".id, "Item"."parentId", "Item"."userId"
FROM base p
JOIN "Item" ON "Item".id = p."parentId" AND "Item"."userId" = p."userId" AND "Item".created_at > now_utc() - within)
SELECT count(*) INTO self_replies FROM base;
RETURN repeats + self_replies;
END;
$$;
CREATE OR REPLACE FUNCTION create_item(
title TEXT, url TEXT, text TEXT, boost INTEGER,
parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER,
has_img_link BOOLEAN, spam_within INTERVAL)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
cost INTEGER;
free_posts INTEGER;
free_comments INTEGER;
freebie BOOLEAN;
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats, "freePosts", "freeComments"
INTO user_msats, free_posts, free_comments
FROM users WHERE id = user_id;
freebie := (parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 0);
cost := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within)) * CASE WHEN has_img_link THEN 10 ELSE 1 END;
IF NOT freebie AND cost > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
INSERT INTO "Item" (title, url, text, "userId", "parentId", "fwdUserId", "paidImgLink", created_at, updated_at)
VALUES (title, url, text, user_id, parent_id, fwd_user_id, has_img_link, now_utc(), now_utc()) RETURNING * INTO item;
IF freebie THEN
IF parent_id IS NULL THEN
UPDATE users SET "freePosts" = "freePosts" - 1 WHERE id = user_id;
ELSE
UPDATE users SET "freeComments" = "freeComments" - 1 WHERE id = user_id;
END IF;
ELSE
UPDATE users SET msats = msats - cost WHERE id = user_id;
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
VALUES (cost / 1000, item.id, user_id, 'VOTE', now_utc(), now_utc());
END IF;
IF boost > 0 THEN
PERFORM item_act(item.id, user_id, 'BOOST', boost);
END IF;
RETURN item;
END;
$$;

View File

@ -0,0 +1,107 @@
CREATE OR REPLACE FUNCTION update_item(item_id INTEGER,
item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER,
fwd_user_id INTEGER, has_img_link BOOLEAN)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
prior_cost INTEGER;
prior_act_id INTEGER;
cost INTEGER;
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT * INTO item FROM "Item" WHERE id = item_id;
-- if has_img_link we need to figure out new costs, which is their prior_cost * 9
IF has_img_link AND NOT item."paidImgLink" THEN
SELECT sats * 1000, id INTO prior_cost, prior_act_id
FROM "ItemAct"
WHERE act = 'VOTE' AND "itemId" = item.id AND "userId" = item."userId";
cost := prior_cost * 9;
IF cost > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
UPDATE users SET msats = msats - cost WHERE id = item."userId";
UPDATE "ItemAct" SET sats = (prior_cost + cost) / 1000 WHERE id = prior_act_id;
END IF;
UPDATE "Item" set title = item_title, url = item_url, text = item_text, "fwdUserId" = fwd_user_id, "paidImgLink" = has_img_link
WHERE id = item_id
RETURNING * INTO item;
IF boost > 0 THEN
PERFORM item_act(item.id, item."userId", 'BOOST', boost);
END IF;
RETURN item;
END;
$$;
CREATE OR REPLACE FUNCTION create_poll(
title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER,
options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN, spam_within INTERVAL)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := create_item(title, null, text, boost, null, user_id, fwd_user_id, has_img_link, spam_within);
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
FOREACH option IN ARRAY options LOOP
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
END LOOP;
RETURN item;
END;
$$;
CREATE OR REPLACE FUNCTION update_poll(
id INTEGER, title TEXT, text TEXT, boost INTEGER,
options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := update_item(id, title, null, text, boost, fwd_user_id, has_img_link);
FOREACH option IN ARRAY options LOOP
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
END LOOP;
RETURN item;
END;
$$;
CREATE OR REPLACE FUNCTION create_bio(title TEXT, text TEXT, user_id INTEGER, has_img_link BOOLEAN)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT * INTO item FROM create_item(title, NULL, text, 0, NULL, user_id, NULL, has_img_link, '0');
UPDATE users SET "bioId" = item.id WHERE id = user_id;
RETURN item;
END;
$$;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
-- CreateEnum
CREATE TYPE "EarnType" AS ENUM ('POST', 'COMMENT', 'TIP_COMMENT', 'TIP_POST');
-- AlterTable
ALTER TABLE "Earn" ADD COLUMN "rank" INTEGER,
ADD COLUMN "type" "EarnType",
ADD COLUMN "typeId" INTEGER;
-- CreateIndex
CREATE INDEX "Earn.created_at_userId_index" ON "Earn"("created_at", "userId");

View File

@ -0,0 +1,16 @@
CREATE OR REPLACE FUNCTION earn(user_id INTEGER, earn_msats INTEGER, created_at TIMESTAMP(3),
type "EarnType", type_id INTEGER, rank INTEGER)
RETURNS void AS $$
DECLARE
BEGIN
PERFORM ASSERT_SERIALIZED();
-- insert into earn
INSERT INTO "Earn" (msats, "userId", created_at, type, "typeId", rank)
VALUES (earn_msats, user_id, created_at, type, type_id, rank);
-- give the user the sats
UPDATE users
SET msats = msats + earn_msats, "stackedMsats" = "stackedMsats" + earn_msats
WHERE id = user_id;
END;
$$ LANGUAGE plpgsql;

View File

@ -0,0 +1,8 @@
-- AlterEnum
ALTER TYPE "ItemActType" ADD VALUE 'DONT_LIKE_THIS';
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "weightedDownVotes" DOUBLE PRECISION NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "wildWestMode" BOOLEAN NOT NULL DEFAULT false;

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