Merge branch 'master' into master
This commit is contained in:
commit
1c45f651eb
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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`
|
||||||
|
}
|
||||||
|
@ -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 })
|
||||||
|
@ -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')
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return latest.createdAt
|
return latest?.createdAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
},
|
},
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -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 {
|
||||||
|
@ -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!
|
||||||
}
|
}
|
||||||
|
@ -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'}
|
||||||
|
@ -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
74
components/avatar.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import AvatarEditor from 'react-avatar-editor'
|
||||||
|
import { Button, Modal, Form as BootstrapForm } from 'react-bootstrap'
|
||||||
|
import Upload from './upload'
|
||||||
|
import EditImage from '../svgs/image-edit-fill.svg'
|
||||||
|
import Moon from '../svgs/moon-fill.svg'
|
||||||
|
|
||||||
|
export default function Avatar ({ onSuccess }) {
|
||||||
|
const [uploading, setUploading] = useState()
|
||||||
|
const [editProps, setEditProps] = useState()
|
||||||
|
const ref = useRef()
|
||||||
|
const [scale, setScale] = useState(1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
show={!!editProps}
|
||||||
|
onHide={() => setEditProps(null)}
|
||||||
|
>
|
||||||
|
<div className='modal-close' onClick={() => setEditProps(null)}>X</div>
|
||||||
|
<Modal.Body className='text-right mt-1 p-4'>
|
||||||
|
<AvatarEditor
|
||||||
|
ref={ref} width={200} height={200}
|
||||||
|
image={editProps?.file}
|
||||||
|
scale={scale}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BootstrapForm.Group controlId='formBasicRange'>
|
||||||
|
<BootstrapForm.Control
|
||||||
|
type='range' onChange={e => setScale(parseFloat(e.target.value))}
|
||||||
|
min={1} max={2} step='0.05'
|
||||||
|
defaultValue={scale} custom
|
||||||
|
/>
|
||||||
|
</BootstrapForm.Group>
|
||||||
|
<Button onClick={() => {
|
||||||
|
ref.current.getImageScaledToCanvas().toBlob(blob => {
|
||||||
|
if (blob) {
|
||||||
|
editProps.upload(blob)
|
||||||
|
setEditProps(null)
|
||||||
|
}
|
||||||
|
}, 'image/jpeg')
|
||||||
|
}}
|
||||||
|
>save
|
||||||
|
</Button>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
|
<Upload
|
||||||
|
as={({ onClick }) =>
|
||||||
|
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
|
||||||
|
{uploading
|
||||||
|
? <Moon className='fill-white spin' />
|
||||||
|
: <EditImage className='fill-white' />}
|
||||||
|
</div>}
|
||||||
|
onError={e => {
|
||||||
|
console.log(e)
|
||||||
|
setUploading(false)
|
||||||
|
}}
|
||||||
|
onSelect={(file, upload) => {
|
||||||
|
setEditProps({ file, upload })
|
||||||
|
}}
|
||||||
|
onSuccess={async key => {
|
||||||
|
onSuccess && onSuccess(key)
|
||||||
|
setUploading(false)
|
||||||
|
}}
|
||||||
|
onStarted={() => {
|
||||||
|
setUploading(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -3,6 +3,7 @@ import * as Yup from 'yup'
|
|||||||
import { gql, useMutation } from '@apollo/client'
|
import { 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>
|
||||||
)
|
)
|
||||||
|
@ -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`}>
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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 => (
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
54
components/dont-link-this.js
Normal file
54
components/dont-link-this.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { gql, useMutation } from '@apollo/client'
|
||||||
|
import { Dropdown } from 'react-bootstrap'
|
||||||
|
import MoreIcon from '../svgs/more-fill.svg'
|
||||||
|
import { useFundError } from './fund-error'
|
||||||
|
|
||||||
|
export default function DontLikeThis ({ id }) {
|
||||||
|
const { setError } = useFundError()
|
||||||
|
|
||||||
|
const [dontLikeThis] = useMutation(
|
||||||
|
gql`
|
||||||
|
mutation dontLikeThis($id: ID!) {
|
||||||
|
dontLikeThis(id: $id)
|
||||||
|
}`, {
|
||||||
|
update (cache) {
|
||||||
|
cache.modify({
|
||||||
|
id: `Item:${id}`,
|
||||||
|
fields: {
|
||||||
|
meDontLike () {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown className='pointer' as='span'>
|
||||||
|
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
|
||||||
|
<MoreIcon className='fill-grey ml-1' height={16} width={16} />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item
|
||||||
|
className='text-center'
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await dontLikeThis({
|
||||||
|
variables: { id },
|
||||||
|
optimisticResponse: { dontLikeThis: true }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error.toString().includes('insufficient funds')) {
|
||||||
|
setError(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
flag
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
}
|
119
components/fee-button.js
Normal file
119
components/fee-button.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { Table } from 'react-bootstrap'
|
||||||
|
import ActionTooltip from './action-tooltip'
|
||||||
|
import Info from './info'
|
||||||
|
import styles from './fee-button.module.css'
|
||||||
|
import { gql, useQuery } from '@apollo/client'
|
||||||
|
import { useFormikContext } from 'formik'
|
||||||
|
|
||||||
|
function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
|
||||||
|
return (
|
||||||
|
<Table className={styles.receipt} borderless size='sm'>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{baseFee} sats</td>
|
||||||
|
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
|
||||||
|
</tr>
|
||||||
|
{hasImgLink &&
|
||||||
|
<tr>
|
||||||
|
<td>x 10</td>
|
||||||
|
<td align='right' className='font-weight-light'>image/link fee</td>
|
||||||
|
</tr>}
|
||||||
|
{repetition > 0 &&
|
||||||
|
<tr>
|
||||||
|
<td>x 10<sup>{repetition}</sup></td>
|
||||||
|
<td className='font-weight-light' align='right'>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</td>
|
||||||
|
</tr>}
|
||||||
|
{boost > 0 &&
|
||||||
|
<tr>
|
||||||
|
<td>+ {boost} sats</td>
|
||||||
|
<td className='font-weight-light' align='right'>boost</td>
|
||||||
|
</tr>}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td className='font-weight-bold'>{cost} sats</td>
|
||||||
|
<td align='right' className='font-weight-light'>total fee</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow }) {
|
||||||
|
const query = parentId
|
||||||
|
? gql`{ itemRepetition(parentId: "${parentId}") }`
|
||||||
|
: gql`{ itemRepetition }`
|
||||||
|
const { data } = useQuery(query, { pollInterval: 1000 })
|
||||||
|
const repetition = data?.itemRepetition || 0
|
||||||
|
const formik = useFormikContext()
|
||||||
|
const boost = formik?.values?.boost || 0
|
||||||
|
const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost)
|
||||||
|
|
||||||
|
const show = alwaysShow || !formik?.isSubmitting
|
||||||
|
return (
|
||||||
|
<div className='d-flex align-items-center'>
|
||||||
|
<ActionTooltip overlayText={`${cost} sats`}>
|
||||||
|
<ChildButton variant={variant}>{text}{cost > baseFee && show && <small> {cost} sats</small>}</ChildButton>
|
||||||
|
</ActionTooltip>
|
||||||
|
{cost > baseFee && show &&
|
||||||
|
<Info>
|
||||||
|
<Receipt baseFee={baseFee} hasImgLink={hasImgLink} repetition={repetition} cost={cost} parentId={parentId} boost={boost} />
|
||||||
|
</Info>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) {
|
||||||
|
return (
|
||||||
|
<Table className={styles.receipt} borderless size='sm'>
|
||||||
|
<tbody>
|
||||||
|
{addImgLink &&
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td>{paidSats} sats</td>
|
||||||
|
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>x 10</td>
|
||||||
|
<td align='right' className='font-weight-light'>image/link fee</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>- {paidSats} sats</td>
|
||||||
|
<td align='right' className='font-weight-light'>already paid</td>
|
||||||
|
</tr>
|
||||||
|
</>}
|
||||||
|
{boost > 0 &&
|
||||||
|
<tr>
|
||||||
|
<td>+ {boost} sats</td>
|
||||||
|
<td className='font-weight-light' align='right'>boost</td>
|
||||||
|
</tr>}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td className='font-weight-bold'>{cost} sats</td>
|
||||||
|
<td align='right' className='font-weight-light'>total fee</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, variant, text, alwaysShow, parentId }) {
|
||||||
|
const formik = useFormikContext()
|
||||||
|
const boost = formik?.values?.boost || 0
|
||||||
|
const addImgLink = hasImgLink && !hadImgLink
|
||||||
|
const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost)
|
||||||
|
|
||||||
|
const show = alwaysShow || !formik?.isSubmitting
|
||||||
|
return (
|
||||||
|
<div className='d-flex align-items-center'>
|
||||||
|
<ActionTooltip overlayText={`${cost} sats`}>
|
||||||
|
<ChildButton variant={variant}>{text}{cost > 0 && show && <small> {cost} sats</small>}</ChildButton>
|
||||||
|
</ActionTooltip>
|
||||||
|
{cost > 0 && show &&
|
||||||
|
<Info>
|
||||||
|
<EditReceipt paidSats={paidSats} addImgLink={addImgLink} cost={cost} parentId={parentId} boost={boost} />
|
||||||
|
</Info>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
15
components/fee-button.module.css
Normal file
15
components/fee-button.module.css
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
.receipt {
|
||||||
|
background-color: var(--theme-inputBg);
|
||||||
|
max-width: 250px;
|
||||||
|
margin: auto;
|
||||||
|
table-layout: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt td {
|
||||||
|
padding: .25rem .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt tfoot {
|
||||||
|
border-top: 2px solid var(--theme-borderColor);
|
||||||
|
}
|
@ -34,7 +34,7 @@ const COLORS = {
|
|||||||
grey: '#707070',
|
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>}
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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
91
components/item-job.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import * as Yup from 'yup'
|
||||||
|
import Toc from './table-of-contents'
|
||||||
|
import { Button, Image } from 'react-bootstrap'
|
||||||
|
import { SearchTitle } from './item'
|
||||||
|
import styles from './item.module.css'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { timeSince } from '../lib/time'
|
||||||
|
import EmailIcon from '../svgs/mail-open-line.svg'
|
||||||
|
|
||||||
|
export default function ItemJob ({ item, toc, rank, children }) {
|
||||||
|
const isEmail = Yup.string().email().isValidSync(item.url)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rank
|
||||||
|
? (
|
||||||
|
<div className={`${styles.rank} pb-2 align-self-center`}>
|
||||||
|
{rank}
|
||||||
|
</div>)
|
||||||
|
: <div />}
|
||||||
|
<div className={`${styles.item}`}>
|
||||||
|
<Link href={`/items/${item.id}`} passHref>
|
||||||
|
<a>
|
||||||
|
<Image
|
||||||
|
src={item.uploadId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${item.uploadId}` : '/jobs-default.png'} width='42' height='42' className={styles.companyImage}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
<div className={`${styles.hunk} align-self-center mb-0`}>
|
||||||
|
<div className={`${styles.main} flex-wrap d-inline`}>
|
||||||
|
<Link href={`/items/${item.id}`} passHref>
|
||||||
|
<a className={`${styles.title} text-reset mr-2`}>
|
||||||
|
{item.searchTitle
|
||||||
|
? <SearchTitle title={item.searchTitle} />
|
||||||
|
: (
|
||||||
|
<>{item.title}</>)}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.other}`}>
|
||||||
|
{item.company &&
|
||||||
|
<>
|
||||||
|
{item.company}
|
||||||
|
</>}
|
||||||
|
{(item.location || item.remote) &&
|
||||||
|
<>
|
||||||
|
<span> \ </span>
|
||||||
|
{`${item.location || ''}${item.location && item.remote ? ' or ' : ''}${item.remote ? 'Remote' : ''}`}
|
||||||
|
</>}
|
||||||
|
<wbr />
|
||||||
|
<span> \ </span>
|
||||||
|
<span>
|
||||||
|
<Link href={`/${item.user.name}`} passHref>
|
||||||
|
<a>@{item.user.name}</a>
|
||||||
|
</Link>
|
||||||
|
<span> </span>
|
||||||
|
<Link href={`/items/${item.id}`} passHref>
|
||||||
|
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
{item.mine &&
|
||||||
|
<>
|
||||||
|
<wbr />
|
||||||
|
<span> \ </span>
|
||||||
|
<Link href={`/items/${item.id}/edit`} passHref>
|
||||||
|
<a className='text-reset'>
|
||||||
|
edit
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
{item.status !== 'ACTIVE' && <span className='ml-1 font-weight-bold text-boost'> {item.status}</span>}
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{toc && <Toc text={item.text} />}
|
||||||
|
</div>
|
||||||
|
{children && (
|
||||||
|
<div className={`${styles.children}`} style={{ marginLeft: 'calc(42px + .8rem)' }}>
|
||||||
|
<div className='mb-3 d-flex'>
|
||||||
|
<Button
|
||||||
|
target='_blank' href={isEmail ? `mailto:${item.url}?subject=${encodeURIComponent(item.title)} via Stacker News` : item.url}
|
||||||
|
>
|
||||||
|
apply {isEmail && <EmailIcon className='ml-1' />}
|
||||||
|
</Button>
|
||||||
|
{isEmail && <div className='ml-3 align-self-center text-muted font-weight-bold'>{item.url}</div>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -7,98 +7,20 @@ import Countdown from './countdown'
|
|||||||
import { NOFOLLOW_LIMIT } from '../lib/constants'
|
import { 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}>
|
||||||
|
@ -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
47
components/items-mixed.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import React from 'react'
|
||||||
|
import { ignoreClick } from '../lib/clicks'
|
||||||
|
import Comment from './comment'
|
||||||
|
import Item from './item'
|
||||||
|
import ItemJob from './item-job'
|
||||||
|
import { ItemsSkeleton } from './items'
|
||||||
|
import styles from './items.module.css'
|
||||||
|
import MoreFooter from './more-footer'
|
||||||
|
|
||||||
|
export default function MixedItems ({ rank, items, cursor, fetchMore }) {
|
||||||
|
const router = useRouter()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<React.Fragment key={item.id}>
|
||||||
|
{item.parentId
|
||||||
|
? (
|
||||||
|
<><div />
|
||||||
|
<div
|
||||||
|
className='pb-1 mb-1 clickToContext' onClick={e => {
|
||||||
|
if (ignoreClick(e)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push({
|
||||||
|
pathname: '/items/[id]',
|
||||||
|
query: { id: item.root.id, commentId: item.id }
|
||||||
|
}, `/items/${item.root.id}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Comment item={item} noReply includeParent clickToContext />
|
||||||
|
</div>
|
||||||
|
</>)
|
||||||
|
: (item.isJob
|
||||||
|
? <ItemJob item={item} rank={rank && i + 1} />
|
||||||
|
: <Item item={item} rank={rank && i + 1} />)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<MoreFooter
|
||||||
|
cursor={cursor} fetchMore={fetchMore}
|
||||||
|
Skeleton={() => <ItemsSkeleton rank={rank} startRank={items.length} />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from '@apollo/client'
|
import { 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 (
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
|
@ -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 (
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
107
components/poll-form.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { Form, Input, MarkdownInput, SubmitButton, VariableInput } from '../components/form'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import * as Yup from 'yup'
|
||||||
|
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||||
|
import Countdown from './countdown'
|
||||||
|
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
|
||||||
|
import { MAX_TITLE_LENGTH, MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES } from '../lib/constants'
|
||||||
|
import TextareaAutosize from 'react-textarea-autosize'
|
||||||
|
import FeeButton, { EditFeeButton } from './fee-button'
|
||||||
|
|
||||||
|
export function PollForm ({ item, editThreshold }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const client = useApolloClient()
|
||||||
|
|
||||||
|
const [upsertPoll] = useMutation(
|
||||||
|
gql`
|
||||||
|
mutation upsertPoll($id: ID, $title: String!, $text: String,
|
||||||
|
$options: [String!]!, $boost: Int, $forward: String) {
|
||||||
|
upsertPoll(id: $id, title: $title, text: $text,
|
||||||
|
options: $options, boost: $boost, forward: $forward) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const PollSchema = Yup.object({
|
||||||
|
title: Yup.string().required('required').trim()
|
||||||
|
.max(MAX_TITLE_LENGTH,
|
||||||
|
({ max, value }) => `${Math.abs(max - value.length)} too many`),
|
||||||
|
options: Yup.array().of(
|
||||||
|
Yup.string().trim().test('my-test', 'required', function (value) {
|
||||||
|
return (this.path !== 'options[0]' && this.path !== 'options[1]') || value
|
||||||
|
}).max(MAX_POLL_CHOICE_LENGTH,
|
||||||
|
({ max, value }) => `${Math.abs(max - value.length)} too many`)
|
||||||
|
),
|
||||||
|
...AdvPostSchema(client)
|
||||||
|
})
|
||||||
|
|
||||||
|
const initialOptions = item?.poll?.options.map(i => i.option)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
initial={{
|
||||||
|
title: item?.title || '',
|
||||||
|
text: item?.text || '',
|
||||||
|
options: initialOptions || ['', ''],
|
||||||
|
...AdvPostInitial({ forward: item?.fwdUser?.name })
|
||||||
|
}}
|
||||||
|
schema={PollSchema}
|
||||||
|
onSubmit={async ({ boost, title, options, ...values }) => {
|
||||||
|
const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
|
||||||
|
const { error } = await upsertPoll({
|
||||||
|
variables: {
|
||||||
|
id: item?.id,
|
||||||
|
boost: Number(boost),
|
||||||
|
title: title.trim(),
|
||||||
|
options: optionsFiltered,
|
||||||
|
...values
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
throw new Error({ message: error.toString() })
|
||||||
|
}
|
||||||
|
if (item) {
|
||||||
|
await router.push(`/items/${item.id}`)
|
||||||
|
} else {
|
||||||
|
await router.push('/recent')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
storageKeyPrefix={item ? undefined : 'poll'}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label='title'
|
||||||
|
name='title'
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<MarkdownInput
|
||||||
|
topLevel
|
||||||
|
label={<>text <small className='text-muted ml-2'>optional</small></>}
|
||||||
|
name='text'
|
||||||
|
as={TextareaAutosize}
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<VariableInput
|
||||||
|
label='choices'
|
||||||
|
name='options'
|
||||||
|
readOnlyLen={initialOptions?.length}
|
||||||
|
max={MAX_POLL_NUM_CHOICES}
|
||||||
|
hint={editThreshold
|
||||||
|
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
|
||||||
|
: null}
|
||||||
|
/>
|
||||||
|
<AdvPostForm edit={!!item} />
|
||||||
|
<div className='mt-3'>
|
||||||
|
{item
|
||||||
|
? <EditFeeButton
|
||||||
|
paidSats={item.meSats}
|
||||||
|
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
|
||||||
|
/>
|
||||||
|
: <FeeButton
|
||||||
|
baseFee={1} parentId={null} text='post'
|
||||||
|
ChildButton={SubmitButton} variant='secondary'
|
||||||
|
/>}
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
99
components/poll.js
Normal file
99
components/poll.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { gql, useMutation } from '@apollo/client'
|
||||||
|
import { Button } from 'react-bootstrap'
|
||||||
|
import { fixedDecimal } from '../lib/format'
|
||||||
|
import { timeLeft } from '../lib/time'
|
||||||
|
import { useMe } from './me'
|
||||||
|
import styles from './poll.module.css'
|
||||||
|
import Check from '../svgs/checkbox-circle-fill.svg'
|
||||||
|
import { signIn } from 'next-auth/client'
|
||||||
|
import { useFundError } from './fund-error'
|
||||||
|
import ActionTooltip from './action-tooltip'
|
||||||
|
|
||||||
|
export default function Poll ({ item }) {
|
||||||
|
const me = useMe()
|
||||||
|
const { setError } = useFundError()
|
||||||
|
const [pollVote] = useMutation(
|
||||||
|
gql`
|
||||||
|
mutation pollVote($id: ID!) {
|
||||||
|
pollVote(id: $id)
|
||||||
|
}`, {
|
||||||
|
update (cache, { data: { pollVote } }) {
|
||||||
|
cache.modify({
|
||||||
|
id: `Item:${item.id}`,
|
||||||
|
fields: {
|
||||||
|
poll (existingPoll) {
|
||||||
|
const poll = { ...existingPoll }
|
||||||
|
poll.meVoted = true
|
||||||
|
poll.count += 1
|
||||||
|
return poll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cache.modify({
|
||||||
|
id: `PollOption:${pollVote}`,
|
||||||
|
fields: {
|
||||||
|
count (existingCount) {
|
||||||
|
return existingCount + 1
|
||||||
|
},
|
||||||
|
meVoted () {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const PollButton = ({ v }) => {
|
||||||
|
return (
|
||||||
|
<ActionTooltip placement='left' notForm>
|
||||||
|
<Button
|
||||||
|
variant='outline-info' className={styles.pollButton}
|
||||||
|
onClick={me
|
||||||
|
? async () => {
|
||||||
|
try {
|
||||||
|
await pollVote({
|
||||||
|
variables: { id: v.id },
|
||||||
|
optimisticResponse: {
|
||||||
|
pollVote: v.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error.toString().includes('insufficient funds')) {
|
||||||
|
setError(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: signIn}
|
||||||
|
>
|
||||||
|
{v.option}
|
||||||
|
</Button>
|
||||||
|
</ActionTooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresIn = timeLeft(new Date(+new Date(item.createdAt) + 864e5))
|
||||||
|
const mine = item.user.id === me?.id
|
||||||
|
return (
|
||||||
|
<div className={styles.pollBox}>
|
||||||
|
{item.poll.options.map(v =>
|
||||||
|
expiresIn && !item.poll.meVoted && !mine
|
||||||
|
? <PollButton key={v.id} v={v} />
|
||||||
|
: <PollResult
|
||||||
|
key={v.id} v={v}
|
||||||
|
progress={item.poll.count ? fixedDecimal(v.count * 100 / item.poll.count, 1) : 0}
|
||||||
|
/>)}
|
||||||
|
<div className='text-muted mt-1'>{item.poll.count} votes \ {expiresIn ? `${expiresIn} left` : 'poll ended'}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PollResult ({ v, progress }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.pollResult}>
|
||||||
|
<span className={styles.pollOption}>{v.option}{v.meVoted && <Check className='fill-grey ml-1 align-self-center' width={18} height={18} />}</span>
|
||||||
|
<span className='ml-auto mr-2 align-self-center'>{progress}%</span>
|
||||||
|
<div className={styles.pollProgress} style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
45
components/poll.module.css
Normal file
45
components/poll.module.css
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
.pollButton {
|
||||||
|
margin-top: .25rem;
|
||||||
|
display: block;
|
||||||
|
border: 2px solid var(--info);
|
||||||
|
border-radius: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 0rem 1.1rem;
|
||||||
|
height: 2rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pollBox {
|
||||||
|
padding-top: .5rem;
|
||||||
|
padding-right: 15px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pollResult {
|
||||||
|
text-transform: uppercase;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
height: 2rem;
|
||||||
|
margin-top: .25rem;
|
||||||
|
display: flex;
|
||||||
|
border-radius: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pollProgress {
|
||||||
|
content: '\A';
|
||||||
|
border-radius: .4rem 0rem 0rem .4rem;
|
||||||
|
position: absolute;
|
||||||
|
background: var(--theme-clickToContextColor);
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pollResult .pollOption {
|
||||||
|
align-self: center;
|
||||||
|
margin-left: .5rem;
|
||||||
|
display: flex;
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import React, { useContext, useEffect, useState } from 'react'
|
import 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
35
components/recent-header.js
Normal file
35
components/recent-header.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Nav, Navbar } from 'react-bootstrap'
|
||||||
|
import styles from './header.module.css'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function RecentHeader ({ itemType }) {
|
||||||
|
return (
|
||||||
|
<Navbar className='pt-0'>
|
||||||
|
<Nav
|
||||||
|
className={`${styles.navbarNav} justify-content-around`}
|
||||||
|
activeKey={itemType}
|
||||||
|
>
|
||||||
|
<Nav.Item>
|
||||||
|
<Link href='/recent' passHref>
|
||||||
|
<Nav.Link
|
||||||
|
eventKey='posts'
|
||||||
|
className={styles.navLink}
|
||||||
|
>
|
||||||
|
posts
|
||||||
|
</Nav.Link>
|
||||||
|
</Link>
|
||||||
|
</Nav.Item>
|
||||||
|
<Nav.Item>
|
||||||
|
<Link href='/recent/comments' passHref>
|
||||||
|
<Nav.Link
|
||||||
|
eventKey='comments'
|
||||||
|
className={styles.navLink}
|
||||||
|
>
|
||||||
|
comments
|
||||||
|
</Nav.Link>
|
||||||
|
</Link>
|
||||||
|
</Nav.Item>
|
||||||
|
</Nav>
|
||||||
|
</Navbar>
|
||||||
|
)
|
||||||
|
}
|
@ -4,11 +4,11 @@ import { gql, useMutation } from '@apollo/client'
|
|||||||
import styles from './reply.module.css'
|
import 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>
|
||||||
|
@ -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())
|
||||||
|
91
components/table-of-contents.js
Normal file
91
components/table-of-contents.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Dropdown, FormControl } from 'react-bootstrap'
|
||||||
|
import TocIcon from '../svgs/list-unordered.svg'
|
||||||
|
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||||
|
import { visit } from 'unist-util-visit'
|
||||||
|
import { toString } from 'mdast-util-to-string'
|
||||||
|
import GithubSlugger from 'github-slugger'
|
||||||
|
|
||||||
|
export default function Toc ({ text }) {
|
||||||
|
if (!text || text.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = fromMarkdown(text)
|
||||||
|
const toc = []
|
||||||
|
const slugger = new GithubSlugger()
|
||||||
|
visit(tree, 'heading', (node, position, parent) => {
|
||||||
|
const str = toString(node)
|
||||||
|
toc.push({ heading: str, slug: slugger.slug(str.replace(/[^\w\-\s]+/gi, '')), depth: node.depth })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (toc.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown alignRight className='d-flex align-items-center'>
|
||||||
|
<Dropdown.Toggle as={CustomToggle} id='dropdown-custom-components'>
|
||||||
|
<TocIcon className='mx-2 fill-grey theme' />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
|
||||||
|
<Dropdown.Menu as={CustomMenu}>
|
||||||
|
{toc.map(v => {
|
||||||
|
return (
|
||||||
|
<Dropdown.Item
|
||||||
|
className={v.depth === 1 ? 'font-weight-bold' : ''}
|
||||||
|
style={{
|
||||||
|
marginLeft: `${(v.depth - 1) * 5}px`
|
||||||
|
}}
|
||||||
|
key={v.slug} href={`#${v.slug}`}
|
||||||
|
>{v.heading}
|
||||||
|
</Dropdown.Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomToggle = React.forwardRef(({ children, onClick }, ref) => (
|
||||||
|
<a
|
||||||
|
href=''
|
||||||
|
ref={ref}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onClick(e)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
|
||||||
|
// forwardRef again here!
|
||||||
|
// Dropdown needs access to the DOM of the Menu to measure it
|
||||||
|
const CustomMenu = React.forwardRef(
|
||||||
|
({ children, style, className, 'aria-labelledby': labeledBy }, ref) => {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
style={style}
|
||||||
|
className={className}
|
||||||
|
aria-labelledby={labeledBy}
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
className='mx-3 my-2 w-auto'
|
||||||
|
placeholder='filter'
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<ul className='list-unstyled'>
|
||||||
|
{React.Children.toArray(children).filter(
|
||||||
|
(child) =>
|
||||||
|
!value || child.props.children.toLowerCase().includes(value)
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
@ -11,6 +11,7 @@ import reactStringReplace from 'react-string-replace'
|
|||||||
import React, { useEffect, useState } from 'react'
|
import 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) => {
|
||||||
|
@ -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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,6 +135,19 @@ export default function UpVote ({ item, className }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// update all ancestors
|
||||||
|
item.path.split('.').forEach(id => {
|
||||||
|
if (Number(id) === Number(item.id)) return
|
||||||
|
cache.modify({
|
||||||
|
id: `Item:${id}`,
|
||||||
|
fields: {
|
||||||
|
commentSats (existingCommentSats = 0) {
|
||||||
|
return existingCommentSats + sats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Button, InputGroup, Image, Modal, Form as BootstrapForm } from 'react-bootstrap'
|
import { Button, InputGroup, Image } from 'react-bootstrap'
|
||||||
import Link from 'next/link'
|
import 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)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -29,6 +29,11 @@ export const SUB_ITEMS = gql`
|
|||||||
cursor
|
cursor
|
||||||
items {
|
items {
|
||||||
...ItemFields
|
...ItemFields
|
||||||
|
position
|
||||||
|
},
|
||||||
|
pins {
|
||||||
|
...ItemFields
|
||||||
|
position
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
19
lib/md.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||||
|
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||||
|
import { visit } from 'unist-util-visit'
|
||||||
|
import { gfm } from 'micromark-extension-gfm'
|
||||||
|
|
||||||
|
export function mdHas (md, test) {
|
||||||
|
const tree = fromMarkdown(md, {
|
||||||
|
extensions: [gfm()],
|
||||||
|
mdastExtensions: [gfmFromMarkdown()]
|
||||||
|
})
|
||||||
|
|
||||||
|
let found = false
|
||||||
|
visit(tree, test, () => {
|
||||||
|
found = true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
return found
|
||||||
|
}
|
28
lib/new-comments.js
Normal file
28
lib/new-comments.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
const COMMENTS_VIEW_PREFIX = 'commentsViewedAt'
|
||||||
|
const COMMENTS_NUM_PREFIX = 'commentsViewNum'
|
||||||
|
|
||||||
|
export function commentsViewed (item) {
|
||||||
|
if (!item.parentId && item.lastCommentAt) {
|
||||||
|
localStorage.setItem(`${COMMENTS_VIEW_PREFIX}:${item.id}`, new Date(item.lastCommentAt).getTime())
|
||||||
|
localStorage.setItem(`${COMMENTS_NUM_PREFIX}:${item.id}`, item.ncomments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commentsViewedAfterComment (rootId, createdAt) {
|
||||||
|
localStorage.setItem(`${COMMENTS_VIEW_PREFIX}:${rootId}`, new Date(createdAt).getTime())
|
||||||
|
const existingRootComments = localStorage.getItem(`${COMMENTS_NUM_PREFIX}:${rootId}`) || 0
|
||||||
|
localStorage.setItem(`${COMMENTS_NUM_PREFIX}:${rootId}`, existingRootComments + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newComments (item) {
|
||||||
|
if (!item.parentId) {
|
||||||
|
const commentsViewedAt = localStorage.getItem(`${COMMENTS_VIEW_PREFIX}:${item.id}`)
|
||||||
|
const commentsViewNum = localStorage.getItem(`${COMMENTS_NUM_PREFIX}:${item.id}`)
|
||||||
|
|
||||||
|
if (commentsViewedAt && commentsViewNum) {
|
||||||
|
return commentsViewedAt < new Date(item.lastCommentAt).getTime() || commentsViewNum < item.ncomments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
22
lib/time.js
22
lib/time.js
@ -19,3 +19,25 @@ export function timeSince (timeStamp) {
|
|||||||
|
|
||||||
return 'now'
|
return 'now'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function timeLeft (timeStamp) {
|
||||||
|
const now = new Date()
|
||||||
|
const secondsPast = (timeStamp - now.getTime()) / 1000
|
||||||
|
|
||||||
|
if (secondsPast < 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secondsPast < 60) {
|
||||||
|
return parseInt(secondsPast) + 's'
|
||||||
|
}
|
||||||
|
if (secondsPast < 3600) {
|
||||||
|
return parseInt(secondsPast / 60) + 'm'
|
||||||
|
}
|
||||||
|
if (secondsPast <= 86400) {
|
||||||
|
return parseInt(secondsPast / 3600) + 'h'
|
||||||
|
}
|
||||||
|
if (secondsPast > 86400) {
|
||||||
|
return parseInt(secondsPast / (3600 * 24)) + ' days'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -55,6 +55,10 @@ module.exports = withPlausibleProxy()({
|
|||||||
source: '/story',
|
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'
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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)
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
})
|
})
|
||||||
|
@ -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
32
pages/borderland.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import Layout from '../components/layout'
|
||||||
|
import { ItemsSkeleton } from '../components/items'
|
||||||
|
import { getGetServerSideProps } from '../api/ssrApollo'
|
||||||
|
import { BORDERLAND_ITEMS } from '../fragments/items'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import MixedItems from '../components/items-mixed'
|
||||||
|
|
||||||
|
export const getServerSideProps = getGetServerSideProps(BORDERLAND_ITEMS)
|
||||||
|
|
||||||
|
export default function Index ({ data: { borderlandItems: { items, cursor } } }) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Items
|
||||||
|
items={items} cursor={cursor}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Items ({ rank, items, cursor }) {
|
||||||
|
const { data, fetchMore } = useQuery(BORDERLAND_ITEMS)
|
||||||
|
|
||||||
|
if (!data && !items) {
|
||||||
|
return <ItemsSkeleton rank={rank} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
({ borderlandItems: { items, cursor } } = data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MixedItems items={items} cursor={cursor} rank={rank} fetchMore={fetchMore} />
|
||||||
|
}
|
32
pages/freebie.js
Normal file
32
pages/freebie.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import Layout from '../components/layout'
|
||||||
|
import { ItemsSkeleton } from '../components/items'
|
||||||
|
import { getGetServerSideProps } from '../api/ssrApollo'
|
||||||
|
import { FREEBIE_ITEMS } from '../fragments/items'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import MixedItems from '../components/items-mixed'
|
||||||
|
|
||||||
|
export const getServerSideProps = getGetServerSideProps(FREEBIE_ITEMS)
|
||||||
|
|
||||||
|
export default function Index ({ data: { freebieItems: { items, cursor } } }) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Items
|
||||||
|
items={items} cursor={cursor}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Items ({ rank, items, cursor }) {
|
||||||
|
const { data, fetchMore } = useQuery(FREEBIE_ITEMS)
|
||||||
|
|
||||||
|
if (!data && !items) {
|
||||||
|
return <ItemsSkeleton rank={rank} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
({ freebieItems: { items, cursor } } = data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MixedItems items={items} cursor={cursor} rank={rank} fetchMore={fetchMore} />
|
||||||
|
}
|
@ -4,6 +4,7 @@ import { DiscussionForm } from '../../../components/discussion-form'
|
|||||||
import { LinkForm } from '../../../components/link-form'
|
import { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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
32
pages/outlawed.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import Layout from '../components/layout'
|
||||||
|
import { ItemsSkeleton } from '../components/items'
|
||||||
|
import { getGetServerSideProps } from '../api/ssrApollo'
|
||||||
|
import { OUTLAWED_ITEMS } from '../fragments/items'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import MixedItems from '../components/items-mixed'
|
||||||
|
|
||||||
|
export const getServerSideProps = getGetServerSideProps(OUTLAWED_ITEMS)
|
||||||
|
|
||||||
|
export default function Index ({ data: { outlawedItems: { items, cursor } } }) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Items
|
||||||
|
items={items} cursor={cursor}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Items ({ rank, items, cursor }) {
|
||||||
|
const { data, fetchMore } = useQuery(OUTLAWED_ITEMS)
|
||||||
|
|
||||||
|
if (!data && !items) {
|
||||||
|
return <ItemsSkeleton rank={rank} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
({ outlawedItems: { items, cursor } } = data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MixedItems items={items} cursor={cursor} rank={rank} fetchMore={fetchMore} />
|
||||||
|
}
|
@ -6,6 +6,8 @@ import { useMe } from '../components/me'
|
|||||||
import { DiscussionForm } from '../components/discussion-form'
|
import { 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
20
pages/recent/comments.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import Layout from '../../components/layout'
|
||||||
|
import { getGetServerSideProps } from '../../api/ssrApollo'
|
||||||
|
import { MORE_FLAT_COMMENTS } from '../../fragments/comments'
|
||||||
|
import CommentsFlat from '../../components/comments-flat'
|
||||||
|
import RecentHeader from '../../components/recent-header'
|
||||||
|
|
||||||
|
const variables = { sort: 'recent' }
|
||||||
|
export const getServerSideProps = getGetServerSideProps(MORE_FLAT_COMMENTS, variables)
|
||||||
|
|
||||||
|
export default function Index ({ data: { moreFlatComments: { comments, cursor } } }) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<RecentHeader itemType='comments' />
|
||||||
|
<CommentsFlat
|
||||||
|
comments={comments} cursor={cursor}
|
||||||
|
variables={{ sort: 'recent' }} includeParent noReply
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
import Layout from '../components/layout'
|
import 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
|
@ -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} />
|
||||||
|
@ -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={
|
||||||
|
@ -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
|
||||||
|
@ -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}`)
|
||||||
|
@ -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>
|
||||||
|
11
prisma/migrations/20220720211644_item_uploads/migration.sql
Normal file
11
prisma/migrations/20220720211644_item_uploads/migration.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[itemId]` on the table `Upload` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Item" ADD COLUMN "uploadId" INTEGER;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Upload.itemId_unique" ON "Upload"("itemId");
|
55
prisma/migrations/20220727194641_polls/migration.sql
Normal file
55
prisma/migrations/20220727194641_polls/migration.sql
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ItemActType" ADD VALUE 'POLL';
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "PostType" ADD VALUE 'POLL';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Item" ADD COLUMN "pollCost" INTEGER;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PollOption" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"itemId" INTEGER NOT NULL,
|
||||||
|
"option" TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PollVote" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"itemId" INTEGER NOT NULL,
|
||||||
|
"pollOptionId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PollOption.itemId_index" ON "PollOption"("itemId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PollVote.itemId_userId_unique" ON "PollVote"("itemId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PollVote.userId_index" ON "PollVote"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PollVote.pollOptionId_index" ON "PollVote"("pollOptionId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PollOption" ADD FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PollVote" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PollVote" ADD FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PollVote" ADD FOREIGN KEY ("pollOptionId") REFERENCES "PollOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,23 @@
|
|||||||
|
-- create poll
|
||||||
|
-- charges us to create poll
|
||||||
|
-- adds poll options to poll
|
||||||
|
CREATE OR REPLACE FUNCTION create_poll(title TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER, options TEXT[])
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
item "Item";
|
||||||
|
option TEXT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
item := create_item(title, null, null, boost, null, user_id);
|
||||||
|
|
||||||
|
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
|
||||||
|
FOREACH option IN ARRAY options LOOP
|
||||||
|
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
112
prisma/migrations/20220727203003_poll_functions2/migration.sql
Normal file
112
prisma/migrations/20220727203003_poll_functions2/migration.sql
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
CREATE OR REPLACE FUNCTION create_poll(title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER, options TEXT[])
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
item "Item";
|
||||||
|
option TEXT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
item := create_item(title, null, text, boost, null, user_id);
|
||||||
|
|
||||||
|
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
|
||||||
|
FOREACH option IN ARRAY options LOOP
|
||||||
|
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- create poll vote
|
||||||
|
-- if user hasn't already voted
|
||||||
|
-- charges user item.pollCost
|
||||||
|
-- adds POLL to ItemAct
|
||||||
|
-- adds PollVote
|
||||||
|
CREATE OR REPLACE FUNCTION poll_vote(option_id INTEGER, user_id INTEGER)
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
item "Item";
|
||||||
|
option "PollOption";
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
SELECT * INTO option FROM "PollOption" where id = option_id;
|
||||||
|
IF option IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'INVALID_POLL_OPTION';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO item FROM "Item" where id = option."itemId";
|
||||||
|
IF item IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'POLL_DOES_NOT_EXIST';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF item."userId" = user_id THEN
|
||||||
|
RAISE EXCEPTION 'POLL_OWNER_CANT_VOTE';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM "PollVote" WHERE "itemId" = item.id AND "userId" = user_id) THEN
|
||||||
|
RAISE EXCEPTION 'POLL_VOTE_ALREADY_EXISTS';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM item_act(item.id, user_id, 'POLL', item."pollCost");
|
||||||
|
|
||||||
|
INSERT INTO "PollVote" (created_at, updated_at, "itemId", "pollOptionId", "userId")
|
||||||
|
VALUES (now_utc(), now_utc(), item.id, option_id, user_id);
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER)
|
||||||
|
RETURNS INTEGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_sats INTEGER;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
SELECT (msats / 1000) INTO user_sats FROM users WHERE id = user_id;
|
||||||
|
IF act_sats > user_sats THEN
|
||||||
|
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- deduct sats from actor
|
||||||
|
UPDATE users SET msats = msats - (act_sats * 1000) WHERE id = user_id;
|
||||||
|
|
||||||
|
IF act = 'BOOST' OR act = 'POLL' THEN
|
||||||
|
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||||
|
VALUES (act_sats, item_id, user_id, act, now_utc(), now_utc());
|
||||||
|
ELSE
|
||||||
|
-- add sats to actee's balance and stacked count
|
||||||
|
UPDATE users
|
||||||
|
SET msats = msats + (act_sats * 1000), "stackedMsats" = "stackedMsats" + (act_sats * 1000)
|
||||||
|
WHERE id = (SELECT COALESCE("fwdUserId", "userId") FROM "Item" WHERE id = item_id);
|
||||||
|
|
||||||
|
-- if they have already voted, this is a tip
|
||||||
|
IF EXISTS (SELECT 1 FROM "ItemAct" WHERE "itemId" = item_id AND "userId" = user_id AND "ItemAct".act = 'VOTE') THEN
|
||||||
|
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||||
|
VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc());
|
||||||
|
ELSE
|
||||||
|
-- else this is a vote with a possible extra tip
|
||||||
|
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||||
|
VALUES (1, item_id, user_id, 'VOTE', now_utc(), now_utc());
|
||||||
|
act_sats := act_sats - 1;
|
||||||
|
|
||||||
|
-- if we have sats left after vote, leave them as a tip
|
||||||
|
IF act_sats > 0 THEN
|
||||||
|
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||||
|
VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc());
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN 1;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN 0;
|
||||||
|
END;
|
||||||
|
$$;
|
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ALTER COLUMN "freeComments" SET DEFAULT 0;
|
5
prisma/migrations/20220810162813_item_spam/migration.sql
Normal file
5
prisma/migrations/20220810162813_item_spam/migration.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Item" ADD COLUMN "paidImgLink" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ALTER COLUMN "freePosts" SET DEFAULT 0;
|
83
prisma/migrations/20220810203210_item_spam2/migration.sql
Normal file
83
prisma/migrations/20220810203210_item_spam2/migration.sql
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
CREATE OR REPLACE FUNCTION item_spam(parent_id INTEGER, user_id INTEGER, within INTERVAL)
|
||||||
|
RETURNS INTEGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
repeats INTEGER;
|
||||||
|
self_replies INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO repeats
|
||||||
|
FROM "Item"
|
||||||
|
WHERE (parent_id IS NULL AND "parentId" IS NULL OR "parentId" = parent_id)
|
||||||
|
AND "userId" = user_id
|
||||||
|
AND created_at > now_utc() - within;
|
||||||
|
|
||||||
|
IF parent_id IS NULL THEN
|
||||||
|
RETURN repeats;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
WITH RECURSIVE base AS (
|
||||||
|
SELECT "Item".id, "Item"."parentId", "Item"."userId"
|
||||||
|
FROM "Item"
|
||||||
|
WHERE id = parent_id AND "userId" = user_id AND created_at > now_utc() - within
|
||||||
|
UNION ALL
|
||||||
|
SELECT "Item".id, "Item"."parentId", "Item"."userId"
|
||||||
|
FROM base p
|
||||||
|
JOIN "Item" ON "Item".id = p."parentId" AND "Item"."userId" = p."userId" AND "Item".created_at > now_utc() - within)
|
||||||
|
SELECT count(*) INTO self_replies FROM base;
|
||||||
|
|
||||||
|
RETURN repeats + self_replies;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION create_item(
|
||||||
|
title TEXT, url TEXT, text TEXT, boost INTEGER,
|
||||||
|
parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER,
|
||||||
|
has_img_link BOOLEAN, spam_within INTERVAL)
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_msats INTEGER;
|
||||||
|
cost INTEGER;
|
||||||
|
free_posts INTEGER;
|
||||||
|
free_comments INTEGER;
|
||||||
|
freebie BOOLEAN;
|
||||||
|
item "Item";
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
SELECT msats, "freePosts", "freeComments"
|
||||||
|
INTO user_msats, free_posts, free_comments
|
||||||
|
FROM users WHERE id = user_id;
|
||||||
|
|
||||||
|
freebie := (parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 0);
|
||||||
|
cost := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within)) * CASE WHEN has_img_link THEN 10 ELSE 1 END;
|
||||||
|
|
||||||
|
IF NOT freebie AND cost > user_msats THEN
|
||||||
|
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO "Item" (title, url, text, "userId", "parentId", "fwdUserId", "paidImgLink", created_at, updated_at)
|
||||||
|
VALUES (title, url, text, user_id, parent_id, fwd_user_id, has_img_link, now_utc(), now_utc()) RETURNING * INTO item;
|
||||||
|
|
||||||
|
IF freebie THEN
|
||||||
|
IF parent_id IS NULL THEN
|
||||||
|
UPDATE users SET "freePosts" = "freePosts" - 1 WHERE id = user_id;
|
||||||
|
ELSE
|
||||||
|
UPDATE users SET "freeComments" = "freeComments" - 1 WHERE id = user_id;
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
UPDATE users SET msats = msats - cost WHERE id = user_id;
|
||||||
|
|
||||||
|
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
|
||||||
|
VALUES (cost / 1000, item.id, user_id, 'VOTE', now_utc(), now_utc());
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF boost > 0 THEN
|
||||||
|
PERFORM item_act(item.id, user_id, 'BOOST', boost);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
107
prisma/migrations/20220815195309_edit_funcs/migration.sql
Normal file
107
prisma/migrations/20220815195309_edit_funcs/migration.sql
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
CREATE OR REPLACE FUNCTION update_item(item_id INTEGER,
|
||||||
|
item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER,
|
||||||
|
fwd_user_id INTEGER, has_img_link BOOLEAN)
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_msats INTEGER;
|
||||||
|
prior_cost INTEGER;
|
||||||
|
prior_act_id INTEGER;
|
||||||
|
cost INTEGER;
|
||||||
|
item "Item";
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
SELECT * INTO item FROM "Item" WHERE id = item_id;
|
||||||
|
|
||||||
|
-- if has_img_link we need to figure out new costs, which is their prior_cost * 9
|
||||||
|
IF has_img_link AND NOT item."paidImgLink" THEN
|
||||||
|
SELECT sats * 1000, id INTO prior_cost, prior_act_id
|
||||||
|
FROM "ItemAct"
|
||||||
|
WHERE act = 'VOTE' AND "itemId" = item.id AND "userId" = item."userId";
|
||||||
|
|
||||||
|
cost := prior_cost * 9;
|
||||||
|
|
||||||
|
IF cost > user_msats THEN
|
||||||
|
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE users SET msats = msats - cost WHERE id = item."userId";
|
||||||
|
|
||||||
|
UPDATE "ItemAct" SET sats = (prior_cost + cost) / 1000 WHERE id = prior_act_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE "Item" set title = item_title, url = item_url, text = item_text, "fwdUserId" = fwd_user_id, "paidImgLink" = has_img_link
|
||||||
|
WHERE id = item_id
|
||||||
|
RETURNING * INTO item;
|
||||||
|
|
||||||
|
IF boost > 0 THEN
|
||||||
|
PERFORM item_act(item.id, item."userId", 'BOOST', boost);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION create_poll(
|
||||||
|
title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER,
|
||||||
|
options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN, spam_within INTERVAL)
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
item "Item";
|
||||||
|
option TEXT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
item := create_item(title, null, text, boost, null, user_id, fwd_user_id, has_img_link, spam_within);
|
||||||
|
|
||||||
|
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
|
||||||
|
FOREACH option IN ARRAY options LOOP
|
||||||
|
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_poll(
|
||||||
|
id INTEGER, title TEXT, text TEXT, boost INTEGER,
|
||||||
|
options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN)
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
item "Item";
|
||||||
|
option TEXT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
item := update_item(id, title, null, text, boost, fwd_user_id, has_img_link);
|
||||||
|
|
||||||
|
FOREACH option IN ARRAY options LOOP
|
||||||
|
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION create_bio(title TEXT, text TEXT, user_id INTEGER, has_img_link BOOLEAN)
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
item "Item";
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
SELECT * INTO item FROM create_item(title, NULL, text, 0, NULL, user_id, NULL, has_img_link, '0');
|
||||||
|
|
||||||
|
UPDATE users SET "bioId" = item.id WHERE id = user_id;
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
43
prisma/migrations/20220827143828_positive_ints/migration.sql
Normal file
43
prisma/migrations/20220827143828_positive_ints/migration.sql
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
-- make sure integers/floats are positive (or null if optional)
|
||||||
|
|
||||||
|
-- users
|
||||||
|
ALTER TABLE users ADD CONSTRAINT "msats_positive" CHECK ("msats" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE users ADD CONSTRAINT "stackedMsats_positive" CHECK ("stackedMsats" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE users ADD CONSTRAINT "freeComments_positive" CHECK ("freeComments" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE users ADD CONSTRAINT "freePosts_positive" CHECK ("freePosts" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE users ADD CONSTRAINT "tipDefault_positive" CHECK ("tipDefault" >= 0) NOT VALID;
|
||||||
|
|
||||||
|
-- upload
|
||||||
|
ALTER TABLE "Upload" ADD CONSTRAINT "size_positive" CHECK ("size" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE "Upload" ADD CONSTRAINT "width_positive" CHECK ("width" IS NULL OR "width" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE "Upload" ADD CONSTRAINT "height_positive" CHECK ("height" IS NULL OR "height" >= 0) NOT VALID;
|
||||||
|
|
||||||
|
-- earn
|
||||||
|
ALTER TABLE "Earn" ADD CONSTRAINT "msats_positive" CHECK ("msats" >= 0) NOT VALID;
|
||||||
|
|
||||||
|
-- invite
|
||||||
|
ALTER TABLE "Invite" ADD CONSTRAINT "gift_positive" CHECK ("gift" IS NULL OR "gift" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE "Invite" ADD CONSTRAINT "limit_positive" CHECK ("limit" IS NULL OR "limit" >= 0) NOT VALID;
|
||||||
|
|
||||||
|
-- item
|
||||||
|
ALTER TABLE "Item" ADD CONSTRAINT "boost_positive" CHECK ("boost" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE "Item" ADD CONSTRAINT "minSalary_positive" CHECK ("minSalary" IS NULL OR "minSalary" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE "Item" ADD CONSTRAINT "maxSalary_positive" CHECK ("maxSalary" IS NULL OR "maxSalary" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE "Item" ADD CONSTRAINT "maxBid_positive" CHECK ("maxBid" IS NULL OR "maxBid" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE "Item" ADD CONSTRAINT "pollCost_positive" CHECK ("pollCost" IS NULL OR "pollCost" >= 0) NOT VALID;
|
||||||
|
|
||||||
|
-- sub
|
||||||
|
ALTER TABLE "Sub" ADD CONSTRAINT "baseCost_positive" CHECK ("baseCost" >= 0) NOT VALID;
|
||||||
|
|
||||||
|
-- item_act
|
||||||
|
ALTER TABLE "ItemAct" ADD CONSTRAINT "sats_positive" CHECK ("sats" >= 0) NOT VALID;
|
||||||
|
|
||||||
|
-- invoice
|
||||||
|
ALTER TABLE "Invoice" ADD CONSTRAINT "msatsRequested_positive" CHECK ("msatsRequested" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE "Invoice" ADD CONSTRAINT "msatsReceived_positive" CHECK ("msatsReceived" IS NULL OR "msatsReceived" >= 0) NOT VALID;
|
||||||
|
|
||||||
|
-- withdrawl
|
||||||
|
ALTER TABLE "Withdrawl" ADD CONSTRAINT "msatsPaying_positive" CHECK ("msatsPaying" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE "Withdrawl" ADD CONSTRAINT "msatsPaid_positive" CHECK ("msatsPaid" IS NULL OR "msatsPaid" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE "Withdrawl" ADD CONSTRAINT "msatsFeePaying_positive" CHECK ("msatsFeePaying" >= 0) NOT VALID;
|
||||||
|
ALTER TABLE "Withdrawl" ADD CONSTRAINT "msatsFeePaid_positive" CHECK ("msatsFeePaid" IS NULL OR "msatsFeePaid" >= 0) NOT VALID;
|
@ -0,0 +1,35 @@
|
|||||||
|
CREATE OR REPLACE FUNCTION create_invoice(hash TEXT, bolt11 TEXT, expires_at timestamp(3) without time zone, msats_req INTEGER, user_id INTEGER)
|
||||||
|
RETURNS "Invoice"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
invoice "Invoice";
|
||||||
|
limit_reached BOOLEAN;
|
||||||
|
too_much BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
SELECT count(*) >= 10, sum("msatsRequested")+max(users.msats)+msats_req > 1000000000 INTO limit_reached, too_much
|
||||||
|
FROM "Invoice"
|
||||||
|
JOIN users on "userId" = users.id
|
||||||
|
WHERE "userId" = user_id AND "expiresAt" > now_utc() AND "confirmedAt" is null AND cancelled = false;
|
||||||
|
|
||||||
|
-- prevent more than 10 pending invoices
|
||||||
|
IF limit_reached THEN
|
||||||
|
RAISE EXCEPTION 'SN_INV_PENDING_LIMIT';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- prevent pending invoices + msats from exceeding 1,000,000 sats
|
||||||
|
IF too_much THEN
|
||||||
|
RAISE EXCEPTION 'SN_INV_EXCEED_BALANCE';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO "Invoice" (hash, bolt11, "expiresAt", "msatsRequested", "userId", created_at, updated_at)
|
||||||
|
VALUES (hash, bolt11, expires_at, msats_req, user_id, now_utc(), now_utc()) RETURNING * INTO invoice;
|
||||||
|
|
||||||
|
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
|
||||||
|
VALUES ('checkInvoice', jsonb_build_object('hash', hash), 21, true, now() + interval '10 seconds');
|
||||||
|
|
||||||
|
RETURN invoice;
|
||||||
|
END;
|
||||||
|
$$;
|
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "hideInvoiceDesc" BOOLEAN NOT NULL DEFAULT false;
|
@ -0,0 +1 @@
|
|||||||
|
CREATE EXTENSION pg_trgm;
|
@ -0,0 +1,86 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Item"
|
||||||
|
ADD COLUMN IF NOT EXISTS "commentSats" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS "lastCommentAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN IF NOT EXISTS "ncomments" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS "sats" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- denormalize all existing comments
|
||||||
|
-- for every item, compute the lastest comment time and number of comments
|
||||||
|
UPDATE "Item"
|
||||||
|
SET "lastCommentAt" = subquery."lastCommentAt", ncomments = subquery.ncomments
|
||||||
|
FROM (
|
||||||
|
SELECT a.id, MAX(b.created_at) AS "lastCommentAt", COUNT(b.id) AS ncomments
|
||||||
|
FROM "Item" a
|
||||||
|
LEFT JOIN "Item" b ON b.path <@ a.path AND a.id <> b.id
|
||||||
|
GROUP BY a.id) subquery
|
||||||
|
WHERE "Item".id = subquery.id;
|
||||||
|
|
||||||
|
-- on comment denormalize comment count and time
|
||||||
|
CREATE OR REPLACE FUNCTION ncomments_after_comment() RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
BEGIN
|
||||||
|
UPDATE "Item"
|
||||||
|
SET "lastCommentAt" = now_utc(), "ncomments" = "ncomments" + 1
|
||||||
|
WHERE id <> NEW.id and path @> NEW.path;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS ncomments_after_comment_trigger ON "Item";
|
||||||
|
CREATE TRIGGER ncomments_after_comment_trigger
|
||||||
|
AFTER INSERT ON "Item"
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE ncomments_after_comment();
|
||||||
|
|
||||||
|
-- denormalize all existing sats
|
||||||
|
-- for every item, compute the total sats it has earned
|
||||||
|
UPDATE "Item"
|
||||||
|
SET sats = subquery.sats
|
||||||
|
FROM (
|
||||||
|
SELECT a.id, SUM(c.sats) AS sats
|
||||||
|
FROM "Item" a
|
||||||
|
JOIN "ItemAct" c ON c."itemId" = a.id AND c."userId" <> a."userId"
|
||||||
|
WHERE c.act IN ('VOTE', 'TIP')
|
||||||
|
GROUP BY a.id) subquery
|
||||||
|
WHERE "Item".id = subquery.id;
|
||||||
|
|
||||||
|
-- denormalize comment sats
|
||||||
|
UPDATE "Item"
|
||||||
|
SET "commentSats" = subquery."commentSats"
|
||||||
|
FROM (
|
||||||
|
SELECT a.id, SUM(b.sats) AS "commentSats"
|
||||||
|
FROM "Item" a
|
||||||
|
JOIN "Item" b ON b.path <@ a.path AND a.id <> b.id
|
||||||
|
GROUP BY a.id) subquery
|
||||||
|
WHERE "Item".id = subquery.id;
|
||||||
|
|
||||||
|
-- on item act denormalize sats and comment sats
|
||||||
|
CREATE OR REPLACE FUNCTION sats_after_act() RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
item "Item";
|
||||||
|
BEGIN
|
||||||
|
SELECT * FROM "Item" WHERE id = NEW."itemId" INTO item;
|
||||||
|
IF item."userId" = NEW."userId" THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE "Item"
|
||||||
|
SET "sats" = "sats" + NEW.sats
|
||||||
|
WHERE id = item.id;
|
||||||
|
|
||||||
|
UPDATE "Item"
|
||||||
|
SET "commentSats" = "commentSats" + NEW.sats
|
||||||
|
WHERE id <> item.id and path @> item.path;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS sats_after_act_trigger ON "ItemAct";
|
||||||
|
CREATE TRIGGER sats_after_act_trigger
|
||||||
|
AFTER INSERT ON "ItemAct"
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (NEW.act = 'VOTE' or NEW.act = 'TIP')
|
||||||
|
EXECUTE PROCEDURE sats_after_act();
|
10
prisma/migrations/20220913173806_earn_columns/migration.sql
Normal file
10
prisma/migrations/20220913173806_earn_columns/migration.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EarnType" AS ENUM ('POST', 'COMMENT', 'TIP_COMMENT', 'TIP_POST');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Earn" ADD COLUMN "rank" INTEGER,
|
||||||
|
ADD COLUMN "type" "EarnType",
|
||||||
|
ADD COLUMN "typeId" INTEGER;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Earn.created_at_userId_index" ON "Earn"("created_at", "userId");
|
16
prisma/migrations/20220913173826_earn_function/migration.sql
Normal file
16
prisma/migrations/20220913173826_earn_function/migration.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
CREATE OR REPLACE FUNCTION earn(user_id INTEGER, earn_msats INTEGER, created_at TIMESTAMP(3),
|
||||||
|
type "EarnType", type_id INTEGER, rank INTEGER)
|
||||||
|
RETURNS void AS $$
|
||||||
|
DECLARE
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
-- insert into earn
|
||||||
|
INSERT INTO "Earn" (msats, "userId", created_at, type, "typeId", rank)
|
||||||
|
VALUES (earn_msats, user_id, created_at, type, type_id, rank);
|
||||||
|
|
||||||
|
-- give the user the sats
|
||||||
|
UPDATE users
|
||||||
|
SET msats = msats + earn_msats, "stackedMsats" = "stackedMsats" + earn_msats
|
||||||
|
WHERE id = user_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
8
prisma/migrations/20220920152500_downvotes/migration.sql
Normal file
8
prisma/migrations/20220920152500_downvotes/migration.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ItemActType" ADD VALUE 'DONT_LIKE_THIS';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Item" ADD COLUMN "weightedDownVotes" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "wildWestMode" BOOLEAN NOT NULL DEFAULT false;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user