Merge branch 'master' into patch-1
This commit is contained in:
commit
60592d4764
|
@ -5,7 +5,7 @@
|
|||
The site is written in javascript using Next.js, a React framework. The backend API is provided via graphql. The database is postgresql modelled with prisma. We use lnd for the lightning node which we connect to through a tor http tunnel. A customized Bootstrap theme is used for styling.
|
||||
|
||||
# processes
|
||||
There are two. 1. the web app and 2. walletd, which checks and polls lnd for all pending invoice/withdrawl statuses in case the web process dies.
|
||||
There are two. 1. the web app and 2. walletd, which checks and polls lnd for all pending invoice/withdrawal statuses in case the web process dies.
|
||||
|
||||
# wallet transaction safety
|
||||
To ensure user balances are kept sane, all wallet updates are run in serializable transactions at the database level. Because prisma has relatively poor support for transactions all wallet touching code is written in plpgsql stored procedures and can be found in the prisma/migrations folder.
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = global.prisma || new PrismaClient()
|
||||
const prisma = global.prisma || new PrismaClient({
|
||||
log: ['warn', 'error']
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV === 'development') global.prisma = prisma
|
||||
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
export const LIMIT = 21
|
||||
|
||||
export function decodeCursor (cursor) {
|
||||
if (!cursor) {
|
||||
return { offset: 0, time: new Date() }
|
||||
} else {
|
||||
const res = JSON.parse(Buffer.from(cursor, 'base64'))
|
||||
res.time = new Date(res.time)
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
export function nextCursorEncoded (cursor) {
|
||||
cursor.offset += LIMIT
|
||||
return Buffer.from(JSON.stringify(cursor)).toString('base64')
|
||||
}
|
|
@ -3,5 +3,6 @@ import message from './message'
|
|||
import item from './item'
|
||||
import wallet from './wallet'
|
||||
import lnurl from './lnurl'
|
||||
import notifications from './notifications'
|
||||
|
||||
export default [user, item, message, wallet, lnurl]
|
||||
export default [user, item, message, wallet, lnurl, notifications]
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
|
||||
import { ensureProtocol } from '../../lib/url'
|
||||
import serialize from './serial'
|
||||
|
||||
const LIMIT = 21
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from './cursor'
|
||||
|
||||
async function comments (models, id) {
|
||||
const flat = await models.$queryRaw(`
|
||||
|
@ -20,21 +19,6 @@ async function comments (models, id) {
|
|||
return nestComments(flat, id)[0]
|
||||
}
|
||||
|
||||
function decodeCursor (cursor) {
|
||||
if (!cursor) {
|
||||
return { offset: 0, time: new Date() }
|
||||
} else {
|
||||
const res = JSON.parse(Buffer.from(cursor, 'base64'))
|
||||
res.time = new Date(res.time)
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
function nextCursorEncoded (cursor) {
|
||||
cursor.offset += LIMIT
|
||||
return Buffer.from(JSON.stringify(cursor)).toString('base64')
|
||||
}
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
moreItems: async (parent, { sort, cursor, userId }, { me, models }) => {
|
||||
|
@ -77,52 +61,33 @@ export default {
|
|||
},
|
||||
moreFlatComments: async (parent, { cursor, userId }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
let comments
|
||||
if (userId) {
|
||||
comments = await models.$queryRaw(`
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "userId" = $1 AND "parentId" IS NOT NULL
|
||||
AND created_at <= $2
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $3
|
||||
LIMIT ${LIMIT}`, Number(userId), decodedCursor.time, decodedCursor.offset)
|
||||
} else {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
}
|
||||
comments = await models.$queryRaw(`
|
||||
${SELECT}
|
||||
From "Item"
|
||||
JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = $1
|
||||
AND "Item"."userId" <> $1 AND "Item".created_at <= $2
|
||||
ORDER BY "Item".created_at DESC
|
||||
OFFSET $3
|
||||
LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset)
|
||||
|
||||
if (!userId) {
|
||||
throw new UserInputError('must supply userId', { argumentName: 'userId' })
|
||||
}
|
||||
|
||||
const comments = await models.$queryRaw(`
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "userId" = $1 AND "parentId" IS NOT NULL
|
||||
AND created_at <= $2
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $3
|
||||
LIMIT ${LIMIT}`, Number(userId), decodedCursor.time, decodedCursor.offset)
|
||||
|
||||
return {
|
||||
cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
comments
|
||||
}
|
||||
},
|
||||
notifications: async (parent, args, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
}
|
||||
|
||||
return await models.$queryRaw(`
|
||||
${SELECT}
|
||||
From "Item"
|
||||
JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = $1
|
||||
AND "Item"."userId" <> $1
|
||||
ORDER BY "Item".created_at DESC`, me.id)
|
||||
},
|
||||
item: async (parent, { id }, { models }) => {
|
||||
const [item] = await models.$queryRaw(`
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE id = $1`, Number(id))
|
||||
item.comments = comments(models, id)
|
||||
if (item) {
|
||||
item.comments = comments(models, id)
|
||||
}
|
||||
return item
|
||||
},
|
||||
userComments: async (parent, { userId }, { models }) => {
|
||||
|
@ -146,24 +111,91 @@ export default {
|
|||
|
||||
return await createItem(parent, { title, url: ensureProtocol(url) }, { me, models })
|
||||
},
|
||||
createDiscussion: async (parent, { title, text }, { me, models }) => {
|
||||
updateLink: async (parent, { id, title, url }, { me, models }) => {
|
||||
if (!id) {
|
||||
throw new UserInputError('link must have id', { argumentName: 'id' })
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
throw new UserInputError('link must have title', { argumentName: 'title' })
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
throw new UserInputError('link must have url', { argumentName: 'url' })
|
||||
}
|
||||
|
||||
// update iff this item belongs to me
|
||||
const item = await models.item.findUnique({ where: { id: Number(id) } })
|
||||
if (Number(item.userId) !== Number(me.id)) {
|
||||
throw new AuthenticationError('item does not belong to you')
|
||||
}
|
||||
|
||||
if (Date.now() > new Date(item.createdAt).getTime() + 10 * 60000) {
|
||||
throw new UserInputError('item can no longer be editted')
|
||||
}
|
||||
|
||||
return await updateItem(parent, { id, data: { title, url: ensureProtocol(url) } }, { me, models })
|
||||
},
|
||||
createDiscussion: async (parent, { title, text }, { me, models }) => {
|
||||
if (!title) {
|
||||
throw new UserInputError('discussion must have title', { argumentName: 'title' })
|
||||
}
|
||||
|
||||
return await createItem(parent, { title, text }, { me, models })
|
||||
},
|
||||
updateDiscussion: async (parent, { id, title, text }, { me, models }) => {
|
||||
if (!id) {
|
||||
throw new UserInputError('discussion must have id', { argumentName: 'id' })
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
throw new UserInputError('discussion must have title', { argumentName: 'title' })
|
||||
}
|
||||
|
||||
// update iff this item belongs to me
|
||||
const item = await models.item.findUnique({ where: { id: Number(id) } })
|
||||
if (Number(item.userId) !== Number(me.id)) {
|
||||
throw new AuthenticationError('item does not belong to you')
|
||||
}
|
||||
|
||||
if (Date.now() > new Date(item.createdAt).getTime() + 10 * 60000) {
|
||||
throw new UserInputError('item can no longer be editted')
|
||||
}
|
||||
|
||||
return await updateItem(parent, { id, data: { title, text } }, { me, models })
|
||||
},
|
||||
createComment: async (parent, { text, parentId }, { me, models }) => {
|
||||
if (!text) {
|
||||
throw new UserInputError('comment must have text', { argumentName: 'text' })
|
||||
}
|
||||
|
||||
if (!parentId) {
|
||||
throw new UserInputError('comment must have parent', { argumentName: 'text' })
|
||||
throw new UserInputError('comment must have parent', { argumentName: 'parentId' })
|
||||
}
|
||||
|
||||
return await createItem(parent, { text, parentId }, { me, models })
|
||||
},
|
||||
updateComment: async (parent, { id, text }, { me, models }) => {
|
||||
if (!text) {
|
||||
throw new UserInputError('comment must have text', { argumentName: 'text' })
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new UserInputError('comment must have id', { argumentName: 'id' })
|
||||
}
|
||||
|
||||
// update iff this comment belongs to me
|
||||
const comment = await models.item.findUnique({ where: { id: Number(id) } })
|
||||
if (Number(comment.userId) !== Number(me.id)) {
|
||||
throw new AuthenticationError('comment does not belong to you')
|
||||
}
|
||||
|
||||
if (Date.now() > new Date(comment.createdAt).getTime() + 10 * 60000) {
|
||||
throw new UserInputError('comment can no longer be editted')
|
||||
}
|
||||
|
||||
return await updateItem(parent, { id, data: { text } }, { me, models })
|
||||
},
|
||||
vote: async (parent, { id, sats = 1 }, { me, models }) => {
|
||||
// need to make sure we are logged in
|
||||
if (!me) {
|
||||
|
@ -251,6 +283,56 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
const namePattern = /\B@[\w_]+/gi
|
||||
|
||||
const createMentions = async (item, models) => {
|
||||
// if we miss a mention, in the rare circumstance there's some kind of
|
||||
// failure, it's not a big deal so we don't do it transactionally
|
||||
// ideally, we probably would
|
||||
if (!item.text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const mentions = item.text.match(namePattern)?.map(m => m.slice(1))
|
||||
if (mentions?.length > 0) {
|
||||
const users = await models.user.findMany({
|
||||
where: {
|
||||
name: { in: mentions }
|
||||
}
|
||||
})
|
||||
|
||||
users.forEach(async user => {
|
||||
const data = {
|
||||
itemId: item.id,
|
||||
userId: user.id
|
||||
}
|
||||
|
||||
await models.mention.upsert({
|
||||
where: {
|
||||
itemId_userId: data
|
||||
},
|
||||
update: data,
|
||||
create: data
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('mention failure', e)
|
||||
}
|
||||
}
|
||||
|
||||
const updateItem = async (parent, { id, data }, { me, models }) => {
|
||||
const item = await models.item.update({
|
||||
where: { id: Number(id) },
|
||||
data
|
||||
})
|
||||
|
||||
await createMentions(item, models)
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
const createItem = async (parent, { title, url, text, parentId }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
|
@ -259,6 +341,9 @@ const createItem = async (parent, { title, url, text, parentId }, { me, models }
|
|||
const [item] = await serialize(models, models.$queryRaw(
|
||||
`${SELECT} FROM create_item($1, $2, $3, $4, $5) AS "Item"`,
|
||||
title, url, text, Number(parentId), me.name))
|
||||
|
||||
await createMentions(item, models)
|
||||
|
||||
item.comments = []
|
||||
return item
|
||||
}
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
import { AuthenticationError } from 'apollo-server-micro'
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from './cursor'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
notifications: async (parent, { cursor }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
}
|
||||
|
||||
/*
|
||||
So that we can cursor over results, we union notifications together ...
|
||||
this requires we have the same number of columns in all results
|
||||
|
||||
select "Item".id, NULL as earnedSats, "Item".created_at as created_at from
|
||||
"Item" JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = 622 AND
|
||||
"Item"."userId" <> 622 UNION ALL select "Item".id, "Vote".sats as earnedSats,
|
||||
"Vote".created_at as created_at FROM "Item" LEFT JOIN "Vote" on
|
||||
"Vote"."itemId" = "Item".id AND "Vote"."userId" <> 622 AND "Vote".boost = false
|
||||
WHERE "Item"."userId" = 622 ORDER BY created_at DESC;
|
||||
|
||||
Because we want to "collapse" time adjacent votes in the result
|
||||
|
||||
select vote.id, sum(vote."earnedSats") as "earnedSats", max(vote.voted_at)
|
||||
as "createdAt" from (select "Item".*, "Vote".sats as "earnedSats",
|
||||
"Vote".created_at as voted_at, ROW_NUMBER() OVER(ORDER BY "Vote".created_at) -
|
||||
ROW_NUMBER() OVER(PARTITION BY "Item".id ORDER BY "Vote".created_at) as island
|
||||
FROM "Item" LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id AND
|
||||
"Vote"."userId" <> 622 AND "Vote".boost = false WHERE "Item"."userId" = 622)
|
||||
as vote group by vote.id, vote.island order by max(vote.voted_at) desc;
|
||||
|
||||
We can also "collapse" votes occuring within 1 hour intervals of each other
|
||||
(I haven't yet combined with the above collapsing method .. but might be
|
||||
overkill)
|
||||
|
||||
select "Item".id, sum("Vote".sats) as earnedSats, max("Vote".created_at)
|
||||
as created_at, ROW_NUMBER() OVER(ORDER BY max("Vote".created_at)) - ROW_NUMBER()
|
||||
OVER(PARTITION BY "Item".id ORDER BY max("Vote".created_at)) as island FROM
|
||||
"Item" LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id AND "Vote"."userId" <> 622
|
||||
AND "Vote".boost = false WHERE "Item"."userId" = 622 group by "Item".id,
|
||||
date_trunc('hour', "Vote".created_at) order by created_at desc;
|
||||
*/
|
||||
|
||||
let notifications = await models.$queryRaw(`
|
||||
SELECT ${ITEM_FIELDS}, "Item".created_at as "sortTime", NULL as "earnedSats",
|
||||
false as mention
|
||||
From "Item"
|
||||
JOIN "Item" p ON "Item"."parentId" = p.id
|
||||
WHERE p."userId" = $1
|
||||
AND "Item"."userId" <> $1 AND "Item".created_at <= $2
|
||||
UNION ALL
|
||||
(SELECT ${ITEM_SUBQUERY_FIELDS}, max(subquery.voted_at) as "sortTime",
|
||||
sum(subquery.sats) as "earnedSats", false as mention
|
||||
FROM
|
||||
(SELECT ${ITEM_FIELDS}, "Vote".created_at as voted_at, "Vote".sats,
|
||||
ROW_NUMBER() OVER(ORDER BY "Vote".created_at) -
|
||||
ROW_NUMBER() OVER(PARTITION BY "Item".id ORDER BY "Vote".created_at) as island
|
||||
FROM "Vote"
|
||||
JOIN "Item" on "Vote"."itemId" = "Item".id
|
||||
WHERE "Vote"."userId" <> $1
|
||||
AND "Vote".created_at <= $2
|
||||
AND "Vote".boost = false
|
||||
AND "Item"."userId" = $1) subquery
|
||||
GROUP BY ${ITEM_SUBQUERY_FIELDS}, subquery.island ORDER BY max(subquery.voted_at) desc)
|
||||
UNION ALL
|
||||
(SELECT ${ITEM_FIELDS}, "Mention".created_at as "sortTime", NULL as "earnedSats",
|
||||
true as mention
|
||||
FROM "Mention"
|
||||
JOIN "Item" on "Mention"."itemId" = "Item".id
|
||||
JOIN "Item" p on "Item"."parentId" = p.id
|
||||
WHERE "Mention"."userId" = $1
|
||||
AND "Mention".created_at <= $2
|
||||
AND "Item"."userId" <> $1
|
||||
AND p."userId" <> $1)
|
||||
ORDER BY "sortTime" DESC
|
||||
OFFSET $3
|
||||
LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset)
|
||||
|
||||
notifications = notifications.map(n => {
|
||||
n.item = { ...n }
|
||||
return n
|
||||
})
|
||||
|
||||
const { checkedNotesAt } = await models.user.findUnique({ where: { id: me.id } })
|
||||
await models.user.update({ where: { id: me.id }, data: { checkedNotesAt: new Date() } })
|
||||
|
||||
return {
|
||||
lastChecked: checkedNotesAt,
|
||||
cursor: notifications.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
notifications
|
||||
}
|
||||
}
|
||||
},
|
||||
Notification: {
|
||||
__resolveType: async (notification, args, { models }) =>
|
||||
notification.earnedSats ? 'Votification' : (notification.mention ? 'Mention' : 'Reply')
|
||||
}
|
||||
}
|
||||
|
||||
const ITEM_SUBQUERY_FIELDS =
|
||||
`subquery.id, subquery."createdAt", subquery."updatedAt", subquery.title, subquery.text,
|
||||
subquery.url, subquery."userId", subquery."parentId", subquery.path`
|
||||
|
||||
const ITEM_FIELDS =
|
||||
`"Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
|
||||
"Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path") AS path`
|
|
@ -18,10 +18,10 @@ async function serialize (models, call) {
|
|||
bail(new Error('wallet balance transaction is not serializable'))
|
||||
}
|
||||
if (error.message.includes('SN_CONFIRMED_WITHDRAWL_EXISTS')) {
|
||||
bail(new Error('withdrawl invoice already confirmed (to withdrawl again create a new invoice)'))
|
||||
bail(new Error('withdrawal invoice already confirmed (to withdraw again create a new invoice)'))
|
||||
}
|
||||
if (error.message.includes('SN_PENDING_WITHDRAWL_EXISTS')) {
|
||||
bail(new Error('withdrawl invoice exists and is pending'))
|
||||
bail(new Error('withdrawal invoice exists and is pending'))
|
||||
}
|
||||
if (error.message.includes('40001')) {
|
||||
throw new Error('wallet balance serialization failure - retry again')
|
||||
|
|
|
@ -25,12 +25,12 @@ export default {
|
|||
|
||||
const [{ sum }] = await models.$queryRaw(`
|
||||
SELECT sum("Vote".sats)
|
||||
FROM "Item"
|
||||
LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id
|
||||
AND "Vote"."userId" <> $1
|
||||
FROM "Vote"
|
||||
JOIN "Item" on "Vote"."itemId" = "Item".id
|
||||
WHERE "Vote"."userId" <> $1
|
||||
AND ("Vote".created_at > $2 OR $2 IS NULL)
|
||||
AND "Vote".boost = false
|
||||
WHERE "Item"."userId" = $1`, user.id, user.checkedNotesAt)
|
||||
AND "Item"."userId" = $1`, user.id, user.checkedNotesAt)
|
||||
|
||||
await models.user.update({ where: { id: me.id }, data: { checkedNotesAt: new Date() } })
|
||||
return sum || 0
|
||||
|
@ -64,9 +64,10 @@ export default {
|
|||
stacked: async (user, args, { models }) => {
|
||||
const [{ sum }] = await models.$queryRaw`
|
||||
SELECT sum("Vote".sats)
|
||||
FROM "Item"
|
||||
LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id AND "Vote"."userId" <> ${user.id} AND boost = false
|
||||
WHERE "Item"."userId" = ${user.id}`
|
||||
FROM "Vote"
|
||||
JOIN "Item" on "Vote"."itemId" = "Item".id
|
||||
WHERE "Vote"."userId" <> ${user.id} AND boost = false
|
||||
AND "Item"."userId" = ${user.id}`
|
||||
return sum || 0
|
||||
},
|
||||
sats: async (user, args, { models }) => {
|
||||
|
@ -77,11 +78,11 @@ export default {
|
|||
const votes = await models.$queryRaw(`
|
||||
SELECT "Vote".id, "Vote".created_at
|
||||
FROM "Vote"
|
||||
LEFT JOIN "Item" on "Vote"."itemId" = "Item".id
|
||||
AND "Vote"."userId" <> $1
|
||||
JOIN "Item" on "Vote"."itemId" = "Item".id
|
||||
WHERE "Vote"."userId" <> $1
|
||||
AND ("Vote".created_at > $2 OR $2 IS NULL)
|
||||
AND "Vote".boost = false
|
||||
WHERE "Item"."userId" = $1
|
||||
AND "Item"."userId" = $1
|
||||
LIMIT 1`, user.id, user.checkedNotesAt)
|
||||
if (votes.length > 0) {
|
||||
return true
|
||||
|
@ -91,10 +92,24 @@ export default {
|
|||
const newReplies = await models.$queryRaw(`
|
||||
SELECT "Item".id, "Item".created_at
|
||||
From "Item"
|
||||
JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = $1
|
||||
JOIN "Item" p ON "Item"."parentId" = p.id
|
||||
WHERE p."userId" = $1
|
||||
AND ("Item".created_at > $2 OR $2 IS NULL) AND "Item"."userId" <> $1
|
||||
LIMIT 1`, user.id, user.checkedNotesAt)
|
||||
return !!newReplies.length
|
||||
if (newReplies.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// check if they have any mentions since checkedNotesAt
|
||||
const newMentions = await models.$queryRaw(`
|
||||
SELECT "Item".id, "Item".created_at
|
||||
From "Mention"
|
||||
JOIN "Item" ON "Mention"."itemId" = "Item".id
|
||||
WHERE "Mention"."userId" = $1
|
||||
AND ("Mention".created_at > $2 OR $2 IS NULL)
|
||||
AND "Item"."userId" <> $1
|
||||
LIMIT 1`, user.id, user.checkedNotesAt)
|
||||
return newMentions.length > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ export default {
|
|||
})
|
||||
|
||||
if (wdrwl.user.id !== me.id) {
|
||||
throw new AuthenticationError('not ur withdrawl')
|
||||
throw new AuthenticationError('not ur withdrawal')
|
||||
}
|
||||
|
||||
return wdrwl
|
||||
|
@ -101,6 +101,11 @@ export default {
|
|||
throw new UserInputError('could not decode invoice')
|
||||
}
|
||||
|
||||
// TODO: test
|
||||
if (!decoded.mtokens || Number(decoded.mtokens) <= 0) {
|
||||
throw new UserInputError('you must specify amount')
|
||||
}
|
||||
|
||||
const msatsFee = Number(maxFee) * 1000
|
||||
|
||||
// create withdrawl transactionally (id, bolt11, amount, fee)
|
||||
|
@ -118,8 +123,11 @@ export default {
|
|||
})
|
||||
|
||||
// if it's confirmed, update confirmed returning extra fees to user
|
||||
sub.on('confirmed', async e => {
|
||||
sub.once('confirmed', async e => {
|
||||
console.log(e)
|
||||
|
||||
sub.removeAllListeners()
|
||||
|
||||
// mtokens also contains the fee
|
||||
const fee = Number(e.fee_mtokens)
|
||||
const paid = Number(e.mtokens) - fee
|
||||
|
@ -130,8 +138,11 @@ export default {
|
|||
// if the payment fails, we need to
|
||||
// 1. return the funds to the user
|
||||
// 2. update the widthdrawl as failed
|
||||
sub.on('failed', async e => {
|
||||
sub.once('failed', async e => {
|
||||
console.log(e)
|
||||
|
||||
sub.removeAllListeners()
|
||||
|
||||
let status = 'UNKNOWN_FAILURE'
|
||||
if (e.is_insufficient_balance) {
|
||||
status = 'INSUFFICIENT_BALANCE'
|
||||
|
|
|
@ -5,6 +5,7 @@ import message from './message'
|
|||
import item from './item'
|
||||
import wallet from './wallet'
|
||||
import lnurl from './lnurl'
|
||||
import notifications from './notifications'
|
||||
|
||||
const link = gql`
|
||||
type Query {
|
||||
|
@ -20,4 +21,4 @@ const link = gql`
|
|||
}
|
||||
`
|
||||
|
||||
export default [link, user, item, message, wallet, lnurl]
|
||||
export default [link, user, item, message, wallet, lnurl, notifications]
|
||||
|
|
|
@ -4,15 +4,17 @@ export default gql`
|
|||
extend type Query {
|
||||
moreItems(sort: String!, cursor: String, userId: ID): Items
|
||||
moreFlatComments(cursor: String, userId: ID): Comments
|
||||
notifications: [Item!]!
|
||||
item(id: ID!): Item
|
||||
userComments(userId: ID!): [Item!]
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
createLink(title: String!, url: String): Item!
|
||||
updateLink(id: ID!, title: String!, url: String): Item!
|
||||
createDiscussion(title: String!, text: String): Item!
|
||||
updateDiscussion(id: ID!, title: String!, text: String): Item!
|
||||
createComment(text: String!, parentId: ID!): Item!
|
||||
updateComment(id: ID!, text: String!): Item!
|
||||
vote(id: ID!, sats: Int): Int!
|
||||
}
|
||||
|
||||
|
@ -42,5 +44,6 @@ export default gql`
|
|||
meSats: Int!
|
||||
ncomments: Int!
|
||||
comments: [Item!]!
|
||||
path: String
|
||||
}
|
||||
`
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
notifications(cursor: String): Notifications
|
||||
}
|
||||
|
||||
type Votification {
|
||||
earnedSats: Int!
|
||||
item: Item!
|
||||
sortTime: String!
|
||||
}
|
||||
|
||||
type Reply {
|
||||
item: Item!
|
||||
sortTime: String!
|
||||
}
|
||||
|
||||
type Mention {
|
||||
mention: Boolean!
|
||||
item: Item!
|
||||
sortTime: String!
|
||||
}
|
||||
|
||||
union Notification = Reply | Votification | Mention
|
||||
|
||||
type Notifications {
|
||||
lastChecked: String
|
||||
cursor: String
|
||||
notifications: [Notification!]!
|
||||
}
|
||||
`
|
|
@ -1,6 +1,12 @@
|
|||
import { useFormikContext } from 'formik'
|
||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
||||
|
||||
export default function ActionTooltip ({ children }) {
|
||||
export default function ActionTooltip ({ children, notForm }) {
|
||||
// if we're in a form, we want to hide tooltip on submit
|
||||
let formik
|
||||
if (!notForm) {
|
||||
formik = useFormikContext()
|
||||
}
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement='bottom'
|
||||
|
@ -10,6 +16,7 @@ export default function ActionTooltip ({ children }) {
|
|||
</Tooltip>
|
||||
}
|
||||
trigger={['hover', 'focus']}
|
||||
show={formik?.isSubmitting ? false : undefined}
|
||||
>
|
||||
{children}
|
||||
</OverlayTrigger>
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
import { Form, MarkdownInput, SubmitButton } from '../components/form'
|
||||
import * as Yup from 'yup'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import styles from './reply.module.css'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import Countdown from '../components/countdown'
|
||||
|
||||
export const CommentSchema = Yup.object({
|
||||
text: Yup.string().required('required').trim()
|
||||
})
|
||||
|
||||
export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) {
|
||||
const [updateComment] = useMutation(
|
||||
gql`
|
||||
mutation updateComment($id: ID! $text: String!) {
|
||||
updateComment(id: $id, text: $text) {
|
||||
text
|
||||
}
|
||||
}`, {
|
||||
update (cache, { data: { updateComment } }) {
|
||||
cache.modify({
|
||||
id: `Item:${comment.id}`,
|
||||
fields: {
|
||||
text () {
|
||||
return updateComment.text
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`${styles.reply} mt-2`}>
|
||||
<Form
|
||||
initial={{
|
||||
text: comment.text
|
||||
}}
|
||||
schema={CommentSchema}
|
||||
onSubmit={async (values, { resetForm }) => {
|
||||
const { error } = await updateComment({ variables: { ...values, id: comment.id } })
|
||||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MarkdownInput
|
||||
name='text'
|
||||
as={TextareaAutosize}
|
||||
minRows={4}
|
||||
autoFocus
|
||||
required
|
||||
groupClassName='mb-0'
|
||||
hint={<Countdown date={editThreshold} />}
|
||||
/>
|
||||
<div className='d-flex align-items-center justify-content-between'>
|
||||
<SubmitButton variant='secondary' className='mt-1'>save</SubmitButton>
|
||||
<div
|
||||
className='font-weight-bold text-muted mr-3'
|
||||
style={{ fontSize: '80%', cursor: 'pointer' }}
|
||||
onClick={onCancel}
|
||||
>
|
||||
cancel
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -9,8 +9,11 @@ import UpVote from './upvote'
|
|||
import Eye from '../svgs/eye-fill.svg'
|
||||
import EyeClose from '../svgs/eye-close-line.svg'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useMe } from './me'
|
||||
import CommentEdit from './comment-edit'
|
||||
import Countdown from './countdown'
|
||||
|
||||
function Parent ({ item }) {
|
||||
function Parent ({ item, rootText }) {
|
||||
const ParentFrag = () => (
|
||||
<>
|
||||
<span> \ </span>
|
||||
|
@ -29,32 +32,34 @@ function Parent ({ item }) {
|
|||
{Number(item.root.id) !== Number(item.parentId) && <ParentFrag />}
|
||||
<span> \ </span>
|
||||
<Link href={`/items/${item.root.id}`} passHref>
|
||||
<a onClick={e => e.stopPropagation()} className='text-reset'>root: {item.root.title}</a>
|
||||
<a onClick={e => e.stopPropagation()} className='text-reset'>{rootText || 'on:'} {item.root.title}</a>
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Comment ({ item, children, replyOpen, includeParent, cacheId, noComments, noReply, clickToContext }) {
|
||||
export default function Comment ({ item, children, replyOpen, includeParent, rootText, noComments, noReply }) {
|
||||
const [reply, setReply] = useState(replyOpen)
|
||||
const [edit, setEdit] = useState()
|
||||
const [collapse, setCollapse] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const router = useRouter()
|
||||
const me = useMe()
|
||||
const mine = me?.id === item.user.id
|
||||
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
|
||||
const [canEdit, setCanEdit] =
|
||||
useState(mine && (Date.now() < editThreshold))
|
||||
|
||||
useEffect(() => {
|
||||
if (Number(router.query.commentId) === Number(item.id)) {
|
||||
ref.current.scrollIntoView()
|
||||
// ref.current.classList.add('flash-it')
|
||||
ref.current.classList.add('flash-it')
|
||||
}
|
||||
}, [item])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref} onClick={() => {
|
||||
if (clickToContext) {
|
||||
router.push(`/items/${item.parentId}?commentId=${item.id}`, `/items/${item.parentId}`)
|
||||
}
|
||||
}} className={includeParent ? `${clickToContext ? styles.clickToContext : ''}` : `${styles.comment} ${collapse ? styles.collapsed : ''}`}
|
||||
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse ? styles.collapsed : ''}`}
|
||||
>
|
||||
<div className={`${itemStyles.item} ${styles.item}`}>
|
||||
<UpVote itemId={item.id} meSats={item.meSats} className={styles.upvote} />
|
||||
|
@ -74,30 +79,70 @@ export default function Comment ({ item, children, replyOpen, includeParent, cac
|
|||
</Link>
|
||||
<span> </span>
|
||||
<span>{timeSince(new Date(item.createdAt))}</span>
|
||||
{includeParent && <Parent item={item} />}
|
||||
{includeParent && <Parent item={item} rootText={rootText} />}
|
||||
</div>
|
||||
{!includeParent && (collapse
|
||||
? <Eye className={styles.collapser} height={10} width={10} onClick={() => setCollapse(false)} />
|
||||
: <EyeClose className={styles.collapser} height={10} width={10} onClick={() => setCollapse(true)} />)}
|
||||
|
||||
</div>
|
||||
<div className={styles.text}>
|
||||
<Text>{item.text}</Text>
|
||||
</div>
|
||||
{edit
|
||||
? (
|
||||
<div className={styles.replyWrapper}>
|
||||
<CommentEdit
|
||||
comment={item}
|
||||
onSuccess={() => {
|
||||
setEdit(!edit)
|
||||
setCanEdit(mine && (Date.now() < editThreshold))
|
||||
}}
|
||||
onCancel={() => {
|
||||
setEdit(!edit)
|
||||
setCanEdit(mine && (Date.now() < editThreshold))
|
||||
}}
|
||||
editThreshold={editThreshold}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={styles.text}>
|
||||
<Text>{item.text}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${itemStyles.children} ${styles.children}`}>
|
||||
{!noReply &&
|
||||
<div
|
||||
className={`${itemStyles.other} ${styles.reply}`}
|
||||
onClick={() => setReply(!reply)}
|
||||
>
|
||||
{reply ? 'cancel' : 'reply'}
|
||||
</div>}
|
||||
<div className={`${styles.children}`}>
|
||||
{!noReply && !edit && (
|
||||
<div className={`${itemStyles.other} ${styles.reply}`}>
|
||||
<div
|
||||
className='d-inline-block'
|
||||
onClick={() => setReply(!reply)}
|
||||
>
|
||||
{reply ? 'cancel' : 'reply'}
|
||||
</div>
|
||||
{canEdit && !reply && !edit &&
|
||||
<>
|
||||
<span> \ </span>
|
||||
<div
|
||||
className='d-inline-block'
|
||||
onClick={() => setEdit(!edit)}
|
||||
>
|
||||
edit
|
||||
<Countdown
|
||||
date={editThreshold}
|
||||
className=' '
|
||||
onComplete={() => {
|
||||
setCanEdit(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={reply ? styles.replyWrapper : 'd-none'}>
|
||||
<Reply
|
||||
parentId={item.id} autoFocus={!replyOpen}
|
||||
onSuccess={() => setReply(replyOpen || false)} cacheId={cacheId}
|
||||
onSuccess={() => setReply(replyOpen || false)}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
|
|
|
@ -45,7 +45,9 @@
|
|||
}
|
||||
|
||||
.children {
|
||||
margin-top: .25rem;
|
||||
margin-top: 0;
|
||||
padding-top: .25rem;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.comments {
|
||||
|
@ -80,15 +82,6 @@
|
|||
padding-left: .2rem;
|
||||
}
|
||||
|
||||
.clickToContext {
|
||||
border-radius: .4rem;
|
||||
padding: .2rem 0;
|
||||
}
|
||||
|
||||
.clickToContext:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.comment:not(:last-child) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
@ -98,9 +91,4 @@
|
|||
padding-top: .25rem;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.clickToContext {
|
||||
scroll-behavior: smooth;
|
||||
cursor: pointer;
|
||||
}
|
|
@ -3,8 +3,11 @@ import Button from 'react-bootstrap/Button'
|
|||
import { MORE_FLAT_COMMENTS } from '../fragments/comments'
|
||||
import { useState } from 'react'
|
||||
import Comment, { CommentSkeleton } from './comment'
|
||||
import styles from './notifications.module.css'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
export default function CommentsFlat ({ variables, ...props }) {
|
||||
const router = useRouter()
|
||||
const { loading, error, data, fetchMore } = useQuery(MORE_FLAT_COMMENTS, {
|
||||
variables
|
||||
})
|
||||
|
@ -12,12 +15,22 @@ export default function CommentsFlat ({ variables, ...props }) {
|
|||
if (loading) {
|
||||
return <CommentsFlatSkeleton />
|
||||
}
|
||||
|
||||
const { moreFlatComments: { comments, cursor } } = data
|
||||
return (
|
||||
<>
|
||||
{comments.map(item => (
|
||||
<Comment key={item.id} item={item} {...props} />
|
||||
<div
|
||||
key={item.id}
|
||||
className={styles.clickToContext}
|
||||
onClick={() => {
|
||||
router.push({
|
||||
pathname: '/items/[id]',
|
||||
query: { id: item.parentId, commentId: item.id }
|
||||
}, `/items/${item.parentId}`)
|
||||
}}
|
||||
>
|
||||
<Comment item={item} {...props} />
|
||||
</div>
|
||||
))}
|
||||
<MoreFooter cursor={cursor} fetchMore={fetchMore} />
|
||||
</>
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import Countdown from 'react-countdown'
|
||||
|
||||
export default function SimpleCountdown ({ className, onComplete, date }) {
|
||||
return (
|
||||
<span className={className || 'text-muted font-weight-bold'}>
|
||||
<Countdown
|
||||
date={date}
|
||||
renderer={props => <span> {props.formatted.minutes}:{props.formatted.seconds}</span>}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
|
||||
import { useRouter } from 'next/router'
|
||||
import * as Yup from 'yup'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import ActionTooltip from '../components/action-tooltip'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import Countdown from './countdown'
|
||||
|
||||
export const DiscussionSchema = Yup.object({
|
||||
title: Yup.string().required('required').trim()
|
||||
})
|
||||
|
||||
export function DiscussionForm ({ item, editThreshold }) {
|
||||
const router = useRouter()
|
||||
const [createDiscussion] = useMutation(
|
||||
gql`
|
||||
mutation createDiscussion($title: String!, $text: String) {
|
||||
createDiscussion(title: $title, text: $text) {
|
||||
id
|
||||
}
|
||||
}`
|
||||
)
|
||||
const [updateDiscussion] = useMutation(
|
||||
gql`
|
||||
mutation updateDiscussion($id: ID!, $title: String!, $text: String!) {
|
||||
updateDiscussion(id: $id, title: $title, text: $text) {
|
||||
id
|
||||
}
|
||||
}`, {
|
||||
update (cache, { data: { updateDiscussion } }) {
|
||||
cache.modify({
|
||||
id: `Item:${item.id}`,
|
||||
fields: {
|
||||
title () {
|
||||
return updateDiscussion.title
|
||||
},
|
||||
text () {
|
||||
return updateDiscussion.text
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
title: item?.title || '',
|
||||
text: item?.text || ''
|
||||
}}
|
||||
schema={DiscussionSchema}
|
||||
onSubmit={async (values) => {
|
||||
let id, error
|
||||
if (item) {
|
||||
({ data: { updateDiscussion: { id } }, error } = await updateDiscussion({ variables: { ...values, id: item.id } }))
|
||||
} else {
|
||||
({ data: { createDiscussion: { id } }, error } = await createDiscussion({ variables: values }))
|
||||
}
|
||||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
router.push(`/items/${id}`)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='title'
|
||||
name='title'
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<MarkdownInput
|
||||
label={<>text <small className='text-muted ml-2'>optional</small></>}
|
||||
name='text'
|
||||
as={TextareaAutosize}
|
||||
minRows={4}
|
||||
hint={editThreshold
|
||||
? <Countdown date={editThreshold} />
|
||||
: null}
|
||||
/>
|
||||
<ActionTooltip>
|
||||
<SubmitButton variant='secondary' className='mt-2'>{item ? 'save' : 'post'}</SubmitButton>
|
||||
</ActionTooltip>
|
||||
</Form>
|
||||
)
|
||||
}
|
|
@ -7,8 +7,10 @@ import Texas from '../svgs/texas.svg'
|
|||
import Github from '../svgs/github-fill.svg'
|
||||
import Twitter from '../svgs/twitter-fill.svg'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
export default function Footer () {
|
||||
const router = useRouter()
|
||||
const query = gql`
|
||||
{
|
||||
connectAddress
|
||||
|
@ -32,12 +34,23 @@ export default function Footer () {
|
|||
placeholder={data.connectAddress}
|
||||
/>
|
||||
</div>}
|
||||
<Link href='/faq' passHref>
|
||||
<a className='text-dark d-inline-flex'>
|
||||
FAQ
|
||||
</a>
|
||||
</Link>
|
||||
<span className='text-muted mx-2'> \ </span>
|
||||
{router.asPath === '/' &&
|
||||
<>
|
||||
<Link href='/faq' passHref>
|
||||
<a className='text-dark d-inline-flex'>
|
||||
FAQ
|
||||
</a>
|
||||
</Link>
|
||||
<span className='text-muted mx-2'> \ </span>
|
||||
<a href='/rss' className='text-dark d-inline-flex' target='_blank'>
|
||||
RSS
|
||||
</a>
|
||||
<span className='text-muted mx-2'> \ </span>
|
||||
<a href='https://plausible.io/stacker.news' className='text-dark d-inline-flex' target='_blank' rel='noreferrer'>
|
||||
Analytics
|
||||
</a>
|
||||
<span className='text-muted mx-2'> \ </span>
|
||||
</>}
|
||||
<small>
|
||||
<a className='text-dark d-inline-flex' href='https://github.com/stackernews/stacker.news'>
|
||||
This is free open source software <Github width={20} height={20} className='mx-1' />
|
||||
|
|
|
@ -89,8 +89,8 @@ export function MarkdownInput ({ label, groupClassName, ...props }) {
|
|||
{...props}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<div className={tab !== 'preview' ? 'd-none' : `${styles.text} form-control`}>
|
||||
<div className={tab !== 'preview' ? 'd-none' : 'form-group'}>
|
||||
<div className={`${styles.text} form-control`}>
|
||||
<Text>{meta.value}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -110,7 +110,7 @@ function FormGroup ({ className, label, children }) {
|
|||
|
||||
function InputInner ({ prepend, append, hint, showValid, ...props }) {
|
||||
const [field, meta] = props.readOnly ? [{}, {}] : useField(props)
|
||||
|
||||
const formik = props.readOnly ? null : useFormikContext()
|
||||
return (
|
||||
<>
|
||||
<InputGroup hasValidation>
|
||||
|
@ -120,6 +120,11 @@ function InputInner ({ prepend, append, hint, showValid, ...props }) {
|
|||
</InputGroup.Prepend>
|
||||
)}
|
||||
<BootstrapForm.Control
|
||||
onKeyDown={(e) => {
|
||||
if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) {
|
||||
formik?.submitForm()
|
||||
}
|
||||
}}
|
||||
{...field} {...props}
|
||||
isInvalid={meta.touched && meta.error}
|
||||
isValid={showValid && meta.touched && !meta.error}
|
||||
|
|
|
@ -14,7 +14,7 @@ import { useEffect } from 'react'
|
|||
import { randInRange } from '../lib/rand'
|
||||
|
||||
function WalletSummary ({ me }) {
|
||||
return `[${me.stacked},${me.sats}]`
|
||||
return `${me.sats} \\ ${me.stacked}`
|
||||
}
|
||||
|
||||
export default function Header () {
|
||||
|
@ -32,20 +32,18 @@ export default function Header () {
|
|||
if (session) {
|
||||
return (
|
||||
<div className='d-flex align-items-center'>
|
||||
{me && me.hasNewNotes &&
|
||||
<Head>
|
||||
<link rel='shortcut icon' href='/favicon-notify.png' />
|
||||
</Head>}
|
||||
<div className='position-relative'>
|
||||
<NavDropdown className='pl-0' title={`@${session.user.name}`} alignRight>
|
||||
<Head>
|
||||
<link rel='shortcut icon' href={me && me.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
|
||||
</Head>
|
||||
<div className='position-relative mr-1'>
|
||||
<NavDropdown className='px-0' title={`@${session.user.name}`} alignRight>
|
||||
<Link href={'/' + session.user.name} passHref>
|
||||
<NavDropdown.Item>profile</NavDropdown.Item>
|
||||
</Link>
|
||||
<Link href='/notifications' passHref>
|
||||
<NavDropdown.Item onClick={() => {
|
||||
// when it's a fresh click evict old notification cache
|
||||
client.cache.evict({ id: 'ROOT_QUERY', fieldName: 'moreFlatComments:{}' })
|
||||
client.cache.evict({ id: 'ROOT_QUERY', fieldName: 'recentlyStacked' })
|
||||
client.cache.evict({ id: 'ROOT_QUERY', fieldName: 'notifications' })
|
||||
}}
|
||||
>
|
||||
notifications
|
||||
|
@ -83,7 +81,7 @@ export default function Header () {
|
|||
{me &&
|
||||
<Nav.Item>
|
||||
<Link href='/wallet' passHref>
|
||||
<Nav.Link className='text-success px-0'><WalletSummary me={me} /></Nav.Link>
|
||||
<Nav.Link className='text-success px-0 text-nowrap'><WalletSummary me={me} /></Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>}
|
||||
</div>
|
||||
|
|
|
@ -2,8 +2,16 @@ import Link from 'next/link'
|
|||
import styles from './item.module.css'
|
||||
import { timeSince } from '../lib/time'
|
||||
import UpVote from './upvote'
|
||||
import { useMe } from './me'
|
||||
import { useState } from 'react'
|
||||
import Countdown from './countdown'
|
||||
|
||||
export default function Item ({ item, rank, children }) {
|
||||
const me = useMe()
|
||||
const mine = me?.id === item.user.id
|
||||
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
|
||||
const [canEdit, setCanEdit] =
|
||||
useState(mine && (Date.now() < editThreshold))
|
||||
return (
|
||||
<>
|
||||
{rank
|
||||
|
@ -43,6 +51,22 @@ export default function Item ({ item, rank, children }) {
|
|||
<span> </span>
|
||||
<span>{timeSince(new Date(item.createdAt))}</span>
|
||||
</span>
|
||||
{canEdit &&
|
||||
<>
|
||||
<span> \ </span>
|
||||
<Link href={`/items/${item.id}/edit`} passHref>
|
||||
<a className='text-reset'>
|
||||
edit
|
||||
<Countdown
|
||||
date={editThreshold}
|
||||
className=' '
|
||||
onComplete={() => {
|
||||
setCanEdit(false)
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -53,7 +53,6 @@
|
|||
.skeleton .other {
|
||||
height: 17px;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.skeleton .title {
|
||||
|
|
|
@ -17,7 +17,7 @@ export default function Layout ({ noContain, noFooter, noSeo, children }) {
|
|||
{noContain
|
||||
? children
|
||||
: (
|
||||
<Container className='mt-1 px-sm-0'>
|
||||
<Container className='px-sm-0'>
|
||||
{children}
|
||||
</Container>
|
||||
)}
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
import { Form, Input, SubmitButton } from '../components/form'
|
||||
import { useRouter } from 'next/router'
|
||||
import * as Yup from 'yup'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import { ensureProtocol } from '../lib/url'
|
||||
import ActionTooltip from '../components/action-tooltip'
|
||||
import Countdown from './countdown'
|
||||
|
||||
export const LinkSchema = Yup.object({
|
||||
title: Yup.string().required('required').trim(),
|
||||
url: Yup.string().test({
|
||||
name: 'url',
|
||||
test: (value) => {
|
||||
try {
|
||||
value = ensureProtocol(value)
|
||||
const valid = new URL(value)
|
||||
return Boolean(valid)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
message: 'invalid url'
|
||||
}).required('required')
|
||||
})
|
||||
|
||||
export function LinkForm ({ item, editThreshold }) {
|
||||
const router = useRouter()
|
||||
const [createLink] = useMutation(
|
||||
gql`
|
||||
mutation createLink($title: String!, $url: String!) {
|
||||
createLink(title: $title, url: $url) {
|
||||
id
|
||||
}
|
||||
}`
|
||||
)
|
||||
const [updateLink] = useMutation(
|
||||
gql`
|
||||
mutation updateLink($id: ID!, $title: String!, $url: String!) {
|
||||
updateLink(id: $id, title: $title, url: $url) {
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
}`, {
|
||||
update (cache, { data: { updateLink } }) {
|
||||
cache.modify({
|
||||
id: `Item:${item.id}`,
|
||||
fields: {
|
||||
title () {
|
||||
return updateLink.title
|
||||
},
|
||||
url () {
|
||||
return updateLink.url
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
title: item?.title || '',
|
||||
url: item?.url || ''
|
||||
}}
|
||||
schema={LinkSchema}
|
||||
onSubmit={async (values) => {
|
||||
let id, error
|
||||
if (item) {
|
||||
({ data: { updateLink: { id } }, error } = await updateLink({ variables: { ...values, id: item.id } }))
|
||||
} else {
|
||||
({ data: { createLink: { id } }, error } = await createLink({ variables: values }))
|
||||
}
|
||||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
router.push(`/items/${id}`)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='title'
|
||||
name='title'
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label='url'
|
||||
name='url'
|
||||
required
|
||||
hint={editThreshold
|
||||
? <Countdown date={editThreshold} />
|
||||
: null}
|
||||
/>
|
||||
<ActionTooltip>
|
||||
<SubmitButton variant='secondary' className='mt-2'>{item ? 'save' : 'post'}</SubmitButton>
|
||||
</ActionTooltip>
|
||||
</Form>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
import { useApolloClient, useQuery } from '@apollo/client'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
import { useState } from 'react'
|
||||
import Comment, { CommentSkeleton } from './comment'
|
||||
import Item from './item'
|
||||
import { NOTIFICATIONS } from '../fragments/notifications'
|
||||
import styles from './notifications.module.css'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
function Notification ({ key, n }) {
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={styles.clickToContext}
|
||||
onClick={() => {
|
||||
if (n.__typename === 'Reply' || !n.item.title) {
|
||||
// evict item from cache so that it has current state
|
||||
// e.g. if they previously visited before a recent comment
|
||||
client.cache.evict({ id: `Item:${n.item.parentId}` })
|
||||
router.push({
|
||||
pathname: '/items/[id]',
|
||||
query: { id: n.item.parentId, commentId: n.item.id }
|
||||
}, `/items/${n.item.parentId}`)
|
||||
} else {
|
||||
client.cache.evict({ id: `Item:${n.item.id}` })
|
||||
router.push(`items/${n.item.id}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{n.__typename === 'Votification' &&
|
||||
<small className='font-weight-bold text-success ml-2'>your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats</small>}
|
||||
{n.__typename === 'Mention' &&
|
||||
<small className='font-weight-bold text-info ml-2'>you were mentioned in</small>}
|
||||
<div className={
|
||||
n.__typename === 'Votification' || n.__typename === 'Mention'
|
||||
? `ml-sm-4 ml-3 ${n.item.title ? 'pb-2' : ''}`
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{n.item.title
|
||||
? <Item item={n.item} />
|
||||
: <Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying to you on:' : undefined} clickToContext />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Notifications ({ variables }) {
|
||||
const { loading, error, data, fetchMore } = useQuery(NOTIFICATIONS, {
|
||||
variables
|
||||
})
|
||||
if (error) return <div>Failed to load!</div>
|
||||
if (loading) {
|
||||
return <CommentsFlatSkeleton />
|
||||
}
|
||||
|
||||
const { notifications: { notifications, cursor, lastChecked } } = data
|
||||
|
||||
const [fresh, old] =
|
||||
notifications.reduce((result, n) => {
|
||||
result[new Date(n.sortTime).getTime() > lastChecked ? 0 : 1].push(n)
|
||||
return result
|
||||
},
|
||||
[[], []])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* XXX we shouldn't use the index but we don't have a unique id in this union yet */}
|
||||
<div className={styles.fresh}>
|
||||
{fresh.map((n, i) => (
|
||||
<Notification n={n} key={i} />
|
||||
))}
|
||||
</div>
|
||||
{old.map((n, i) => (
|
||||
<Notification n={n} key={i} />
|
||||
))}
|
||||
<MoreFooter cursor={cursor} fetchMore={fetchMore} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CommentsFlatSkeleton () {
|
||||
const comments = new Array(21).fill(null)
|
||||
|
||||
return (
|
||||
<div>{comments.map((_, i) => (
|
||||
<CommentSkeleton key={i} skeletonChildren={0} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MoreFooter ({ cursor, fetchMore }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
if (loading) {
|
||||
return <div><CommentsFlatSkeleton /></div>
|
||||
}
|
||||
|
||||
let Footer
|
||||
if (cursor) {
|
||||
Footer = () => (
|
||||
<Button
|
||||
variant='primary'
|
||||
size='md'
|
||||
onClick={async () => {
|
||||
setLoading(true)
|
||||
await fetchMore({
|
||||
variables: {
|
||||
cursor
|
||||
}
|
||||
})
|
||||
setLoading(false)
|
||||
}}
|
||||
>more
|
||||
</Button>
|
||||
)
|
||||
} else {
|
||||
Footer = () => (
|
||||
<div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.6' }}>GENISIS</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className='d-flex justify-content-center mt-4 mb-2'><Footer /></div>
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
.clickToContext {
|
||||
border-radius: .4rem;
|
||||
padding: .2rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickToContext:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.fresh {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
border-radius: .4rem;
|
||||
}
|
|
@ -47,7 +47,7 @@ export default function Reply ({ parentId, onSuccess, autoFocus }) {
|
|||
)
|
||||
|
||||
return (
|
||||
<div className={styles.reply}>
|
||||
<div className={`${styles.reply} mb-1`}>
|
||||
<Form
|
||||
initial={{
|
||||
text: ''
|
||||
|
|
|
@ -7,7 +7,7 @@ export default function Seo ({ item, user }) {
|
|||
const pathNoQuery = router.asPath.split('?')[0]
|
||||
const defaultTitle = pathNoQuery.slice(1)
|
||||
let fullTitle = `${defaultTitle && `${defaultTitle} \\ `}stacker news`
|
||||
let desc = 'Bitcoin news powered by the Lightning Network.'
|
||||
let desc = "It's like Hacker News but we pay you Bitcoin."
|
||||
if (item) {
|
||||
if (item.title) {
|
||||
fullTitle = `${item.title} \\ stacker news`
|
||||
|
|
|
@ -36,7 +36,7 @@ export default function UpVote ({ itemId, meSats, className }) {
|
|||
return (
|
||||
<LightningConsumer>
|
||||
{({ strike }) =>
|
||||
<ActionTooltip>
|
||||
<ActionTooltip notForm>
|
||||
<UpArrow
|
||||
width={24}
|
||||
height={24}
|
||||
|
|
|
@ -31,7 +31,7 @@ export default function UserHeader ({ user }) {
|
|||
const client = useApolloClient()
|
||||
const [setName] = useMutation(NAME_MUTATION)
|
||||
|
||||
const Satistics = () => <h1 className='ml-2'><small className='text-success'>[{user.stacked} stacked, {user.sats} sats]</small></h1>
|
||||
const Satistics = () => <h1 className='ml-2'><small className='text-success'>{user.sats} sats \ {user.stacked} stacked</small></h1>
|
||||
|
||||
const UserSchema = Yup.object({
|
||||
name: Yup.string()
|
||||
|
|
|
@ -8,6 +8,7 @@ export const COMMENT_FIELDS = gql`
|
|||
text
|
||||
user {
|
||||
name
|
||||
id
|
||||
}
|
||||
sats
|
||||
boost
|
||||
|
|
|
@ -9,6 +9,7 @@ export const ITEM_FIELDS = gql`
|
|||
url
|
||||
user {
|
||||
name
|
||||
id
|
||||
}
|
||||
sats
|
||||
boost
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import { ITEM_FIELDS } from './items'
|
||||
|
||||
export const NOTIFICATIONS = gql`
|
||||
${ITEM_FIELDS}
|
||||
|
||||
query Notifications($cursor: String) {
|
||||
notifications(cursor: $cursor) {
|
||||
cursor
|
||||
lastChecked
|
||||
notifications {
|
||||
__typename
|
||||
... on Mention {
|
||||
sortTime
|
||||
mention
|
||||
item {
|
||||
...ItemFields
|
||||
text
|
||||
}
|
||||
}
|
||||
... on Votification {
|
||||
sortTime
|
||||
earnedSats
|
||||
item {
|
||||
...ItemFields
|
||||
text
|
||||
}
|
||||
}
|
||||
... on Reply {
|
||||
sortTime
|
||||
item {
|
||||
...ItemFields
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} `
|
|
@ -0,0 +1,47 @@
|
|||
const SITE_URL = 'https://stacker.news'
|
||||
const SITE_TITLE = 'Stacker News'
|
||||
const SITE_SUBTITLE = 'Like Hacker News, but we pay you Bitcoin.'
|
||||
|
||||
function escapeXml (unsafe) {
|
||||
return unsafe.replace(/[<>&'"]/g, function (c) {
|
||||
switch (c) {
|
||||
case '<': return '<'
|
||||
case '>': return '>'
|
||||
case '&': return '&'
|
||||
case '\'': return '''
|
||||
case '"': return '"'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const generateRssItem = (item) => {
|
||||
const guid = `${SITE_URL}/items/${item.id}`
|
||||
const link = item.url || guid
|
||||
return `
|
||||
<item>
|
||||
<guid>${SITE_URL}/items/${item.id}</guid>
|
||||
<title>${escapeXml(item.title)}</title>
|
||||
<link>${link}</link>
|
||||
<comments>${guid}</comments>
|
||||
<description><![CDATA[<a href="${guid}">Comments</a>]]></description>
|
||||
<pubDate>${new Date(item.createdAt).toUTCString()}</pubDate>
|
||||
</item>
|
||||
`
|
||||
}
|
||||
|
||||
export default function generateRssFeed (items) {
|
||||
const itemsList = items.map(generateRssItem)
|
||||
return `
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>${SITE_TITLE}</title>
|
||||
<link>${SITE_URL}</link>
|
||||
<description>${SITE_SUBTITLE}</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
||||
<link>${SITE_URL}</link>
|
||||
${itemsList.join('')}
|
||||
</channel>
|
||||
</rss>
|
||||
`
|
||||
}
|
|
@ -28,6 +28,7 @@
|
|||
"qrcode.react": "^1.0.1",
|
||||
"react": "17.0.1",
|
||||
"react-bootstrap": "^1.5.2",
|
||||
"react-countdown": "^2.3.2",
|
||||
"react-dom": "17.0.1",
|
||||
"react-markdown": "^6.0.2",
|
||||
"react-syntax-highlighter": "^15.4.3",
|
||||
|
@ -8552,6 +8553,18 @@
|
|||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
|
||||
"integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="
|
||||
},
|
||||
"node_modules/react-countdown": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/react-countdown/-/react-countdown-2.3.2.tgz",
|
||||
"integrity": "sha512-Q4SADotHtgOxNWhDdvgupmKVL0pMB9DvoFcxv5AzjsxVhzOVxnttMbAywgqeOdruwEAmnPhOhNv/awAgkwru2w==",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 15",
|
||||
"react-dom": ">= 15"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "17.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.1.tgz",
|
||||
|
@ -17996,6 +18009,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"react-countdown": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/react-countdown/-/react-countdown-2.3.2.tgz",
|
||||
"integrity": "sha512-Q4SADotHtgOxNWhDdvgupmKVL0pMB9DvoFcxv5AzjsxVhzOVxnttMbAywgqeOdruwEAmnPhOhNv/awAgkwru2w==",
|
||||
"requires": {
|
||||
"prop-types": "^15.7.2"
|
||||
}
|
||||
},
|
||||
"react-dom": {
|
||||
"version": "17.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.1.tgz",
|
||||
|
|
|
@ -3,10 +3,10 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "NODE_OPTIONS='--trace-warnings' next dev",
|
||||
"build": "next build",
|
||||
"migrate": "prisma migrate deploy",
|
||||
"start": "next start -p $PORT"
|
||||
"start": "NODE_OPTIONS='--trace-warnings' next start -p $PORT"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.3.13",
|
||||
|
@ -30,6 +30,7 @@
|
|||
"qrcode.react": "^1.0.1",
|
||||
"react": "17.0.1",
|
||||
"react-bootstrap": "^1.5.2",
|
||||
"react-countdown": "^2.3.2",
|
||||
"react-dom": "17.0.1",
|
||||
"react-markdown": "^6.0.2",
|
||||
"react-syntax-highlighter": "^15.4.3",
|
||||
|
@ -62,4 +63,4 @@
|
|||
"eslint-plugin-compat": "^3.9.0",
|
||||
"standard": "^16.0.3"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -39,7 +39,7 @@ export default function UserComments ({ user }) {
|
|||
<Layout noSeo>
|
||||
<Seo user={user} />
|
||||
<UserHeader user={user} />
|
||||
<CommentsFlat variables={{ userId: user.id }} includeParent noReply clickToContext />
|
||||
<CommentsFlat variables={{ userId: user.id }} includeParent noReply />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -49,6 +49,20 @@ const client = new ApolloClient({
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
notifications: {
|
||||
merge (existing, incoming, { readField }) {
|
||||
const notifications = existing ? existing.notifications : []
|
||||
return {
|
||||
cursor: incoming.cursor,
|
||||
notifications: [...notifications, ...incoming.notifications],
|
||||
lastChecked: incoming.lastChecked
|
||||
}
|
||||
},
|
||||
|
||||
read (existing) {
|
||||
return existing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,8 +4,13 @@ import path from 'path'
|
|||
export default async function handler (req, res) {
|
||||
const url = process.env.SELF_URL + '/' + path.join(...(req.query.path || []))
|
||||
res.setHeader('Content-Type', 'image/png')
|
||||
const streams = await new Pageres({ crop: true })
|
||||
try {
|
||||
const streams = await new Pageres({ crop: true })
|
||||
.src(url, ['600x300'])
|
||||
.run()
|
||||
res.status(200).end(streams[0])
|
||||
res.status(200).end(streams[0])
|
||||
} catch(e) {
|
||||
console.log(e)
|
||||
res.status(500)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,11 +5,11 @@ import secp256k1 from 'secp256k1'
|
|||
import models from '../../api/models'
|
||||
|
||||
export default async ({ query }, res) => {
|
||||
const sig = Buffer.from(query.sig, 'hex')
|
||||
const k1 = Buffer.from(query.k1, 'hex')
|
||||
const key = Buffer.from(query.key, 'hex')
|
||||
const signature = secp256k1.signatureImport(sig)
|
||||
try {
|
||||
const sig = Buffer.from(query.sig, 'hex')
|
||||
const k1 = Buffer.from(query.k1, 'hex')
|
||||
const key = Buffer.from(query.key, 'hex')
|
||||
const signature = secp256k1.signatureImport(sig)
|
||||
if (secp256k1.ecdsaVerify(signature, k1, key)) {
|
||||
await models.lnAuth.update({ where: { k1: query.k1 }, data: { pubkey: query.key } })
|
||||
return res.status(200).json({ status: 'OK' })
|
||||
|
@ -17,5 +17,14 @@ export default async ({ query }, res) => {
|
|||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
return res.status(400).json({ status: 'ERROR', reason: 'signature verification failed' })
|
||||
|
||||
let reason = 'signature verification failed'
|
||||
if (!query.sig) {
|
||||
reason = 'no sig query variable provided'
|
||||
} else if (!query.k1) {
|
||||
reason = 'no k1 query variable provided'
|
||||
} else if (!query.key) {
|
||||
reason = 'no key query variable provided'
|
||||
}
|
||||
return res.status(400).json({ status: 'ERROR', reason })
|
||||
}
|
||||
|
|
|
@ -13,6 +13,12 @@ import ApolloClient from '../../api/client'
|
|||
|
||||
// ssr the item without comments so that we can populate metatags
|
||||
export async function getServerSideProps ({ req, params: { id } }) {
|
||||
if (isNaN(id)) {
|
||||
return {
|
||||
notFound: true
|
||||
}
|
||||
}
|
||||
|
||||
const { error, data: { item } } = await (await ApolloClient(req)).query({
|
||||
query:
|
||||
gql`
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { ITEM_FIELDS } from '../../../fragments/items'
|
||||
import { gql } from '@apollo/client'
|
||||
import ApolloClient from '../../../api/client'
|
||||
import { DiscussionForm } from '../../../components/discussion-form'
|
||||
import { LinkForm } from '../../../components/link-form'
|
||||
import LayoutCenter from '../../../components/layout-center'
|
||||
|
||||
export async function getServerSideProps ({ req, params: { id } }) {
|
||||
const { error, data: { item } } = await (await ApolloClient(req)).query({
|
||||
query:
|
||||
gql`
|
||||
${ITEM_FIELDS}
|
||||
{
|
||||
item(id: ${id}) {
|
||||
...ItemFields
|
||||
text
|
||||
}
|
||||
}`
|
||||
})
|
||||
|
||||
if (!item || error) {
|
||||
return {
|
||||
notFound: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function PostEdit ({ item }) {
|
||||
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
|
||||
|
||||
return (
|
||||
<LayoutCenter>
|
||||
{item.url
|
||||
? <LinkForm item={item} editThreshold={editThreshold} />
|
||||
: <DiscussionForm item={item} editThreshold={editThreshold} />}
|
||||
</LayoutCenter>
|
||||
)
|
||||
}
|
|
@ -16,7 +16,7 @@ import { gql, useMutation, useQuery } from '@apollo/client'
|
|||
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
|
||||
const session = await getSession({ req })
|
||||
|
||||
if (session && res && session.accessToken) {
|
||||
if (session && res && callbackUrl) {
|
||||
res.writeHead(302, {
|
||||
Location: callbackUrl
|
||||
})
|
||||
|
|
|
@ -1,28 +1,10 @@
|
|||
import { gql, useQuery } from '@apollo/client'
|
||||
import CommentsFlat from '../components/comments-flat'
|
||||
import Layout from '../components/layout'
|
||||
import Notifications from '../components/notifications'
|
||||
|
||||
export function RecentlyStacked () {
|
||||
const query = gql`
|
||||
{
|
||||
recentlyStacked
|
||||
}`
|
||||
const { data } = useQuery(query)
|
||||
if (!data || !data.recentlyStacked) return null
|
||||
|
||||
return (
|
||||
<h2 className='visible text-success text-center py-3'>
|
||||
you stacked <span className='text-monospace'>{data.recentlyStacked}</span> sats
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Notifications ({ user }) {
|
||||
export default function NotificationPage () {
|
||||
return (
|
||||
<Layout>
|
||||
<RecentlyStacked />
|
||||
<h6 className='text-muted'>replies</h6>
|
||||
<CommentsFlat noReply includeParent clickToContext />
|
||||
<Notifications />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
118
pages/post.js
118
pages/post.js
|
@ -1,124 +1,10 @@
|
|||
import Button from 'react-bootstrap/Button'
|
||||
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
|
||||
import { useRouter } from 'next/router'
|
||||
import Link from 'next/link'
|
||||
import * as Yup from 'yup'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import LayoutCenter from '../components/layout-center'
|
||||
import { ensureProtocol } from '../lib/url'
|
||||
import { useMe } from '../components/me'
|
||||
import ActionTooltip from '../components/action-tooltip'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
|
||||
export const DiscussionSchema = Yup.object({
|
||||
title: Yup.string().required('required').trim()
|
||||
})
|
||||
|
||||
export function DiscussionForm () {
|
||||
const router = useRouter()
|
||||
const [createDiscussion] = useMutation(
|
||||
gql`
|
||||
mutation createDiscussion($title: String!, $text: String) {
|
||||
createDiscussion(title: $title, text: $text) {
|
||||
id
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
title: '',
|
||||
text: ''
|
||||
}}
|
||||
schema={DiscussionSchema}
|
||||
onSubmit={async (values) => {
|
||||
const { data: { createDiscussion: { id } }, error } = await createDiscussion({ variables: values })
|
||||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
router.push(`items/${id}`)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='title'
|
||||
name='title'
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<MarkdownInput
|
||||
label={<>text <small className='text-muted ml-2'>optional</small></>}
|
||||
name='text'
|
||||
as={TextareaAutosize}
|
||||
minRows={4}
|
||||
/>
|
||||
<ActionTooltip>
|
||||
<SubmitButton variant='secondary' className='mt-2'>post</SubmitButton>
|
||||
</ActionTooltip>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export const LinkSchema = Yup.object({
|
||||
title: Yup.string().required('required').trim(),
|
||||
url: Yup.string().test({
|
||||
name: 'url',
|
||||
test: (value) => {
|
||||
try {
|
||||
value = ensureProtocol(value)
|
||||
const valid = new URL(value)
|
||||
return Boolean(valid)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
message: 'invalid url'
|
||||
}).required('required')
|
||||
})
|
||||
|
||||
export function LinkForm () {
|
||||
const router = useRouter()
|
||||
const [createLink] = useMutation(
|
||||
gql`
|
||||
mutation createLink($title: String!, $url: String!) {
|
||||
createLink(title: $title, url: $url) {
|
||||
id
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
title: '',
|
||||
url: ''
|
||||
}}
|
||||
schema={LinkSchema}
|
||||
onSubmit={async (values) => {
|
||||
const { data: { createLink: { id } }, error } = await createLink({ variables: values })
|
||||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
router.push(`items/${id}`)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='title'
|
||||
name='title'
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label='url'
|
||||
name='url'
|
||||
required
|
||||
/>
|
||||
<ActionTooltip>
|
||||
<SubmitButton variant='secondary' className='mt-2'>post</SubmitButton>
|
||||
</ActionTooltip>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
import { DiscussionForm } from '../components/discussion-form'
|
||||
import { LinkForm } from '../components/link-form'
|
||||
|
||||
export function PostForm () {
|
||||
const router = useRouter()
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
|
||||
import ApolloClient from '../api/client'
|
||||
import generateRssFeed from '../lib/rss'
|
||||
import { MORE_ITEMS } from '../fragments/items'
|
||||
|
||||
export default function RssFeed () {
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getServerSideProps ({ req, res }) {
|
||||
const emptyProps = { props: {} } // to avoid server side warnings
|
||||
const { error, data: { moreItems: { items } } } = await (await ApolloClient(req)).query({
|
||||
query: MORE_ITEMS,
|
||||
variables: { sort: 'hot' }
|
||||
})
|
||||
|
||||
if (!items || error) return emptyProps
|
||||
|
||||
res.setHeader('Content-Type', 'text/xml')
|
||||
res.write(generateRssFeed(items))
|
||||
res.end()
|
||||
|
||||
return emptyProps
|
||||
}
|
|
@ -7,7 +7,7 @@ import { gql, useMutation } from '@apollo/client'
|
|||
import { LnQRSkeleton } from '../components/lnqr'
|
||||
import LayoutCenter from '../components/layout-center'
|
||||
import InputGroup from 'react-bootstrap/InputGroup'
|
||||
import { WithdrawlSkeleton } from './withdrawls/[id]'
|
||||
import { WithdrawlSkeleton } from './withdrawals/[id]'
|
||||
import { useMe } from '../components/me'
|
||||
|
||||
export default function Wallet () {
|
||||
|
@ -38,7 +38,7 @@ export function WalletForm () {
|
|||
<Button variant='success'>fund</Button>
|
||||
</Link>
|
||||
<span className='mx-3 font-weight-bold text-muted'>or</span>
|
||||
<Link href='/wallet?type=withdrawl'>
|
||||
<Link href='/wallet?type=withdraw'>
|
||||
<Button variant='success'>withdraw</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -129,7 +129,7 @@ export function WithdrawlForm () {
|
|||
schema={WithdrawlSchema}
|
||||
onSubmit={async ({ invoice, maxFee }) => {
|
||||
const { data } = await createWithdrawl({ variables: { invoice, maxFee: Number(maxFee) } })
|
||||
router.push(`/withdrawls/${data.createWithdrawl.id}`)
|
||||
router.push(`/withdrawals/${data.createWithdrawl.id}`)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
|
@ -144,7 +144,7 @@ export function WithdrawlForm () {
|
|||
required
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<SubmitButton variant='success' className='mt-2'>withdrawl</SubmitButton>
|
||||
<SubmitButton variant='success' className='mt-2'>withdraw</SubmitButton>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
-- Only update path if we have conditions that require us to reset it
|
||||
CREATE OR REPLACE FUNCTION update_item_path() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
npath ltree;
|
||||
BEGIN
|
||||
IF NEW."parentId" IS NULL THEN
|
||||
SELECT NEW.id::text::ltree INTO npath;
|
||||
NEW."path" = npath;
|
||||
ELSEIF TG_OP = 'INSERT' OR OLD."parentId" IS NULL OR OLD."parentId" != NEW."parentId" THEN
|
||||
SELECT "path" || NEW.id::text FROM "Item" WHERE id = NEW."parentId" INTO npath;
|
||||
IF npath IS NULL THEN
|
||||
RAISE EXCEPTION 'Invalid parent_id %', NEW."parentId";
|
||||
END IF;
|
||||
NEW."path" = npath;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
|
@ -0,0 +1,22 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "Mention" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"itemId" INTEGER NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Mention.itemId_index" ON "Mention"("itemId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Mention.userId_index" ON "Mention"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Mention" ADD FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Mention" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[itemId,userId]` on the table `Mention` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Mention.itemId_userId_unique" ON "Mention"("itemId", "userId");
|
|
@ -0,0 +1,2 @@
|
|||
-- add withdrawal as user
|
||||
INSERT INTO "users" ("name") VALUES ('withdrawal');
|
|
@ -0,0 +1,2 @@
|
|||
-- add withdrawals as user
|
||||
INSERT INTO "users" ("name") VALUES ('withdrawals');
|
|
@ -0,0 +1,3 @@
|
|||
-- withdraw users
|
||||
INSERT INTO "users" ("name") VALUES ('withdraw');
|
||||
INSERT INTO "users" ("name") VALUES ('withdraws');
|
|
@ -19,6 +19,7 @@ model User {
|
|||
emailVerified DateTime? @map(name: "email_verified")
|
||||
image String?
|
||||
items Item[]
|
||||
mentions Mention[]
|
||||
messages Message[]
|
||||
votes Vote[]
|
||||
invoices Invoice[]
|
||||
|
@ -60,6 +61,7 @@ model Item {
|
|||
parentId Int?
|
||||
children Item[] @relation("ParentChildren")
|
||||
votes Vote[]
|
||||
mentions Mention[]
|
||||
path Unsupported("LTREE")?
|
||||
|
||||
@@index([userId])
|
||||
|
@ -81,6 +83,20 @@ model Vote {
|
|||
@@index([userId])
|
||||
}
|
||||
|
||||
model Mention {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
item Item @relation(fields: [itemId], references: [id])
|
||||
itemId Int
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
|
||||
@@unique([itemId, userId])
|
||||
@@index([itemId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Invoice {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
|
|
|
@ -191,7 +191,7 @@ footer {
|
|||
}
|
||||
|
||||
.flash-it {
|
||||
animation: flash 2s linear 2;
|
||||
animation: flash 2s linear 1;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
|
|
|
@ -57,7 +57,7 @@ async function checkPendingInvoices () {
|
|||
const inv = await getInvoice({ id: invoice.hash, lnd })
|
||||
await recordInvoiceStatus(inv)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
console.log(invoice, error)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
@ -106,7 +106,7 @@ async function checkPendingWithdrawls () {
|
|||
const wdrwl = await getPayment({ id: withdrawl.hash, lnd })
|
||||
await recordWithdrawlStatus(withdrawl.id, wdrwl)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
console.log(withdrawl, error)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue