Merge branch 'master' into patch-1

This commit is contained in:
huumn 2021-08-20 14:29:31 -05:00 committed by GitHub
commit 60592d4764
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1224 additions and 297 deletions

View File

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

View File

@ -1,6 +1,8 @@
import { PrismaClient } from '@prisma/client' 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 if (process.env.NODE_ENV === 'development') global.prisma = prisma

16
api/resolvers/cursor.js Normal file
View File

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

View File

@ -3,5 +3,6 @@ import message from './message'
import item from './item' import item from './item'
import wallet from './wallet' import wallet from './wallet'
import lnurl from './lnurl' import lnurl from './lnurl'
import notifications from './notifications'
export default [user, item, message, wallet, lnurl] export default [user, item, message, wallet, lnurl, notifications]

View File

@ -1,8 +1,7 @@
import { UserInputError, AuthenticationError } from 'apollo-server-micro' import { UserInputError, AuthenticationError } from 'apollo-server-micro'
import { ensureProtocol } from '../../lib/url' import { ensureProtocol } from '../../lib/url'
import serialize from './serial' import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from './cursor'
const LIMIT = 21
async function comments (models, id) { async function comments (models, id) {
const flat = await models.$queryRaw(` const flat = await models.$queryRaw(`
@ -20,21 +19,6 @@ async function comments (models, id) {
return nestComments(flat, id)[0] 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 { export default {
Query: { Query: {
moreItems: async (parent, { sort, cursor, userId }, { me, models }) => { moreItems: async (parent, { sort, cursor, userId }, { me, models }) => {
@ -77,9 +61,12 @@ export default {
}, },
moreFlatComments: async (parent, { cursor, userId }, { me, models }) => { moreFlatComments: async (parent, { cursor, userId }, { me, models }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
let comments
if (userId) { if (!userId) {
comments = await models.$queryRaw(` throw new UserInputError('must supply userId', { argumentName: 'userId' })
}
const comments = await models.$queryRaw(`
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NOT NULL WHERE "userId" = $1 AND "parentId" IS NOT NULL
@ -87,42 +74,20 @@ export default {
ORDER BY created_at DESC ORDER BY created_at DESC
OFFSET $3 OFFSET $3
LIMIT ${LIMIT}`, Number(userId), decodedCursor.time, decodedCursor.offset) 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)
}
return { return {
cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
comments 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 }) => { item: async (parent, { id }, { models }) => {
const [item] = await models.$queryRaw(` const [item] = await models.$queryRaw(`
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
WHERE id = $1`, Number(id)) WHERE id = $1`, Number(id))
if (item) {
item.comments = comments(models, id) item.comments = comments(models, id)
}
return item return item
}, },
userComments: async (parent, { userId }, { models }) => { userComments: async (parent, { userId }, { models }) => {
@ -146,24 +111,91 @@ export default {
return await createItem(parent, { title, url: ensureProtocol(url) }, { me, models }) 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) { if (!title) {
throw new UserInputError('link must have title', { argumentName: '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 }) 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 }) => { createComment: async (parent, { text, parentId }, { me, models }) => {
if (!text) { if (!text) {
throw new UserInputError('comment must have text', { argumentName: 'text' }) throw new UserInputError('comment must have text', { argumentName: 'text' })
} }
if (!parentId) { 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 }) 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 }) => { vote: async (parent, { id, sats = 1 }, { me, models }) => {
// need to make sure we are logged in // need to make sure we are logged in
if (!me) { 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 }) => { const createItem = async (parent, { title, url, text, parentId }, { me, models }) => {
if (!me) { if (!me) {
throw new AuthenticationError('you must be logged in') 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( const [item] = await serialize(models, models.$queryRaw(
`${SELECT} FROM create_item($1, $2, $3, $4, $5) AS "Item"`, `${SELECT} FROM create_item($1, $2, $3, $4, $5) AS "Item"`,
title, url, text, Number(parentId), me.name)) title, url, text, Number(parentId), me.name))
await createMentions(item, models)
item.comments = [] item.comments = []
return item return item
} }

View File

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

View File

@ -18,10 +18,10 @@ async function serialize (models, call) {
bail(new Error('wallet balance transaction is not serializable')) bail(new Error('wallet balance transaction is not serializable'))
} }
if (error.message.includes('SN_CONFIRMED_WITHDRAWL_EXISTS')) { 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')) { 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')) { if (error.message.includes('40001')) {
throw new Error('wallet balance serialization failure - retry again') throw new Error('wallet balance serialization failure - retry again')

View File

@ -25,12 +25,12 @@ export default {
const [{ sum }] = await models.$queryRaw(` const [{ sum }] = await models.$queryRaw(`
SELECT sum("Vote".sats) SELECT sum("Vote".sats)
FROM "Item" FROM "Vote"
LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id JOIN "Item" on "Vote"."itemId" = "Item".id
AND "Vote"."userId" <> $1 WHERE "Vote"."userId" <> $1
AND ("Vote".created_at > $2 OR $2 IS NULL) AND ("Vote".created_at > $2 OR $2 IS NULL)
AND "Vote".boost = false 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() } }) await models.user.update({ where: { id: me.id }, data: { checkedNotesAt: new Date() } })
return sum || 0 return sum || 0
@ -64,9 +64,10 @@ export default {
stacked: async (user, args, { models }) => { stacked: async (user, args, { models }) => {
const [{ sum }] = await models.$queryRaw` const [{ sum }] = await models.$queryRaw`
SELECT sum("Vote".sats) SELECT sum("Vote".sats)
FROM "Item" FROM "Vote"
LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id AND "Vote"."userId" <> ${user.id} AND boost = false JOIN "Item" on "Vote"."itemId" = "Item".id
WHERE "Item"."userId" = ${user.id}` WHERE "Vote"."userId" <> ${user.id} AND boost = false
AND "Item"."userId" = ${user.id}`
return sum || 0 return sum || 0
}, },
sats: async (user, args, { models }) => { sats: async (user, args, { models }) => {
@ -77,11 +78,11 @@ export default {
const votes = await models.$queryRaw(` const votes = await models.$queryRaw(`
SELECT "Vote".id, "Vote".created_at SELECT "Vote".id, "Vote".created_at
FROM "Vote" FROM "Vote"
LEFT JOIN "Item" on "Vote"."itemId" = "Item".id JOIN "Item" on "Vote"."itemId" = "Item".id
AND "Vote"."userId" <> $1 WHERE "Vote"."userId" <> $1
AND ("Vote".created_at > $2 OR $2 IS NULL) AND ("Vote".created_at > $2 OR $2 IS NULL)
AND "Vote".boost = false AND "Vote".boost = false
WHERE "Item"."userId" = $1 AND "Item"."userId" = $1
LIMIT 1`, user.id, user.checkedNotesAt) LIMIT 1`, user.id, user.checkedNotesAt)
if (votes.length > 0) { if (votes.length > 0) {
return true return true
@ -91,10 +92,24 @@ export default {
const newReplies = await models.$queryRaw(` const newReplies = await models.$queryRaw(`
SELECT "Item".id, "Item".created_at SELECT "Item".id, "Item".created_at
From "Item" 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 AND ("Item".created_at > $2 OR $2 IS NULL) AND "Item"."userId" <> $1
LIMIT 1`, user.id, user.checkedNotesAt) 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
} }
} }
} }

View File

@ -39,7 +39,7 @@ export default {
}) })
if (wdrwl.user.id !== me.id) { if (wdrwl.user.id !== me.id) {
throw new AuthenticationError('not ur withdrawl') throw new AuthenticationError('not ur withdrawal')
} }
return wdrwl return wdrwl
@ -101,6 +101,11 @@ export default {
throw new UserInputError('could not decode invoice') 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 const msatsFee = Number(maxFee) * 1000
// create withdrawl transactionally (id, bolt11, amount, fee) // create withdrawl transactionally (id, bolt11, amount, fee)
@ -118,8 +123,11 @@ export default {
}) })
// if it's confirmed, update confirmed returning extra fees to user // if it's confirmed, update confirmed returning extra fees to user
sub.on('confirmed', async e => { sub.once('confirmed', async e => {
console.log(e) console.log(e)
sub.removeAllListeners()
// mtokens also contains the fee // mtokens also contains the fee
const fee = Number(e.fee_mtokens) const fee = Number(e.fee_mtokens)
const paid = Number(e.mtokens) - fee const paid = Number(e.mtokens) - fee
@ -130,8 +138,11 @@ export default {
// if the payment fails, we need to // if the payment fails, we need to
// 1. return the funds to the user // 1. return the funds to the user
// 2. update the widthdrawl as failed // 2. update the widthdrawl as failed
sub.on('failed', async e => { sub.once('failed', async e => {
console.log(e) console.log(e)
sub.removeAllListeners()
let status = 'UNKNOWN_FAILURE' let status = 'UNKNOWN_FAILURE'
if (e.is_insufficient_balance) { if (e.is_insufficient_balance) {
status = 'INSUFFICIENT_BALANCE' status = 'INSUFFICIENT_BALANCE'

View File

@ -5,6 +5,7 @@ import message from './message'
import item from './item' import item from './item'
import wallet from './wallet' import wallet from './wallet'
import lnurl from './lnurl' import lnurl from './lnurl'
import notifications from './notifications'
const link = gql` const link = gql`
type Query { 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]

View File

@ -4,15 +4,17 @@ export default gql`
extend type Query { extend type Query {
moreItems(sort: String!, cursor: String, userId: ID): Items moreItems(sort: String!, cursor: String, userId: ID): Items
moreFlatComments(cursor: String, userId: ID): Comments moreFlatComments(cursor: String, userId: ID): Comments
notifications: [Item!]!
item(id: ID!): Item item(id: ID!): Item
userComments(userId: ID!): [Item!] userComments(userId: ID!): [Item!]
} }
extend type Mutation { extend type Mutation {
createLink(title: String!, url: String): Item! createLink(title: String!, url: String): Item!
updateLink(id: ID!, title: String!, url: String): Item!
createDiscussion(title: String!, text: String): Item! createDiscussion(title: String!, text: String): Item!
updateDiscussion(id: ID!, title: String!, text: String): Item!
createComment(text: String!, parentId: ID!): Item! createComment(text: String!, parentId: ID!): Item!
updateComment(id: ID!, text: String!): Item!
vote(id: ID!, sats: Int): Int! vote(id: ID!, sats: Int): Int!
} }
@ -42,5 +44,6 @@ export default gql`
meSats: Int! meSats: Int!
ncomments: Int! ncomments: Int!
comments: [Item!]! comments: [Item!]!
path: String
} }
` `

View File

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

View File

@ -1,6 +1,12 @@
import { useFormikContext } from 'formik'
import { OverlayTrigger, Tooltip } from 'react-bootstrap' 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 ( return (
<OverlayTrigger <OverlayTrigger
placement='bottom' placement='bottom'
@ -10,6 +16,7 @@ export default function ActionTooltip ({ children }) {
</Tooltip> </Tooltip>
} }
trigger={['hover', 'focus']} trigger={['hover', 'focus']}
show={formik?.isSubmitting ? false : undefined}
> >
{children} {children}
</OverlayTrigger> </OverlayTrigger>

View File

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

View File

@ -9,8 +9,11 @@ import UpVote from './upvote'
import Eye from '../svgs/eye-fill.svg' import Eye from '../svgs/eye-fill.svg'
import EyeClose from '../svgs/eye-close-line.svg' import EyeClose from '../svgs/eye-close-line.svg'
import { useRouter } from 'next/router' 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 = () => ( const ParentFrag = () => (
<> <>
<span> \ </span> <span> \ </span>
@ -29,32 +32,34 @@ function Parent ({ item }) {
{Number(item.root.id) !== Number(item.parentId) && <ParentFrag />} {Number(item.root.id) !== Number(item.parentId) && <ParentFrag />}
<span> \ </span> <span> \ </span>
<Link href={`/items/${item.root.id}`} passHref> <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> </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 [reply, setReply] = useState(replyOpen)
const [edit, setEdit] = useState()
const [collapse, setCollapse] = useState(false) const [collapse, setCollapse] = useState(false)
const ref = useRef(null) const ref = useRef(null)
const router = useRouter() 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(() => { useEffect(() => {
if (Number(router.query.commentId) === Number(item.id)) { if (Number(router.query.commentId) === Number(item.id)) {
ref.current.scrollIntoView() ref.current.scrollIntoView()
// ref.current.classList.add('flash-it') ref.current.classList.add('flash-it')
} }
}, [item]) }, [item])
return ( return (
<div <div
ref={ref} onClick={() => { ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse ? styles.collapsed : ''}`}
if (clickToContext) {
router.push(`/items/${item.parentId}?commentId=${item.id}`, `/items/${item.parentId}`)
}
}} className={includeParent ? `${clickToContext ? styles.clickToContext : ''}` : `${styles.comment} ${collapse ? styles.collapsed : ''}`}
> >
<div className={`${itemStyles.item} ${styles.item}`}> <div className={`${itemStyles.item} ${styles.item}`}>
<UpVote itemId={item.id} meSats={item.meSats} className={styles.upvote} /> <UpVote itemId={item.id} meSats={item.meSats} className={styles.upvote} />
@ -74,30 +79,70 @@ export default function Comment ({ item, children, replyOpen, includeParent, cac
</Link> </Link>
<span> </span> <span> </span>
<span>{timeSince(new Date(item.createdAt))}</span> <span>{timeSince(new Date(item.createdAt))}</span>
{includeParent && <Parent item={item} />} {includeParent && <Parent item={item} rootText={rootText} />}
</div> </div>
{!includeParent && (collapse {!includeParent && (collapse
? <Eye className={styles.collapser} height={10} width={10} onClick={() => setCollapse(false)} /> ? <Eye className={styles.collapser} height={10} width={10} onClick={() => setCollapse(false)} />
: <EyeClose className={styles.collapser} height={10} width={10} onClick={() => setCollapse(true)} />)} : <EyeClose className={styles.collapser} height={10} width={10} onClick={() => setCollapse(true)} />)}
</div> </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}> <div className={styles.text}>
<Text>{item.text}</Text> <Text>{item.text}</Text>
</div> </div>
)}
</div> </div>
</div> </div>
<div className={`${itemStyles.children} ${styles.children}`}> <div className={`${styles.children}`}>
{!noReply && {!noReply && !edit && (
<div className={`${itemStyles.other} ${styles.reply}`}>
<div <div
className={`${itemStyles.other} ${styles.reply}`} className='d-inline-block'
onClick={() => setReply(!reply)} onClick={() => setReply(!reply)}
> >
{reply ? 'cancel' : 'reply'} {reply ? 'cancel' : 'reply'}
</div>} </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'}> <div className={reply ? styles.replyWrapper : 'd-none'}>
<Reply <Reply
parentId={item.id} autoFocus={!replyOpen} parentId={item.id} autoFocus={!replyOpen}
onSuccess={() => setReply(replyOpen || false)} cacheId={cacheId} onSuccess={() => setReply(replyOpen || false)}
/> />
</div> </div>
{children} {children}

View File

@ -45,7 +45,9 @@
} }
.children { .children {
margin-top: .25rem; margin-top: 0;
padding-top: .25rem;
margin-left: 24px;
} }
.comments { .comments {
@ -80,15 +82,6 @@
padding-left: .2rem; padding-left: .2rem;
} }
.clickToContext {
border-radius: .4rem;
padding: .2rem 0;
}
.clickToContext:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.comment:not(:last-child) { .comment:not(:last-child) {
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
@ -99,8 +92,3 @@
border-top-left-radius: 0; border-top-left-radius: 0;
border-top-right-radius: 0; border-top-right-radius: 0;
} }
.clickToContext {
scroll-behavior: smooth;
cursor: pointer;
}

View File

@ -3,8 +3,11 @@ import Button from 'react-bootstrap/Button'
import { MORE_FLAT_COMMENTS } from '../fragments/comments' import { MORE_FLAT_COMMENTS } from '../fragments/comments'
import { useState } from 'react' import { useState } from 'react'
import Comment, { CommentSkeleton } from './comment' import Comment, { CommentSkeleton } from './comment'
import styles from './notifications.module.css'
import { useRouter } from 'next/router'
export default function CommentsFlat ({ variables, ...props }) { export default function CommentsFlat ({ variables, ...props }) {
const router = useRouter()
const { loading, error, data, fetchMore } = useQuery(MORE_FLAT_COMMENTS, { const { loading, error, data, fetchMore } = useQuery(MORE_FLAT_COMMENTS, {
variables variables
}) })
@ -12,12 +15,22 @@ export default function CommentsFlat ({ variables, ...props }) {
if (loading) { if (loading) {
return <CommentsFlatSkeleton /> return <CommentsFlatSkeleton />
} }
const { moreFlatComments: { comments, cursor } } = data const { moreFlatComments: { comments, cursor } } = data
return ( return (
<> <>
{comments.map(item => ( {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} /> <MoreFooter cursor={cursor} fetchMore={fetchMore} />
</> </>

13
components/countdown.js Normal file
View File

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

View File

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

View File

@ -7,8 +7,10 @@ import Texas from '../svgs/texas.svg'
import Github from '../svgs/github-fill.svg' import Github from '../svgs/github-fill.svg'
import Twitter from '../svgs/twitter-fill.svg' import Twitter from '../svgs/twitter-fill.svg'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/router'
export default function Footer () { export default function Footer () {
const router = useRouter()
const query = gql` const query = gql`
{ {
connectAddress connectAddress
@ -32,12 +34,23 @@ export default function Footer () {
placeholder={data.connectAddress} placeholder={data.connectAddress}
/> />
</div>} </div>}
{router.asPath === '/' &&
<>
<Link href='/faq' passHref> <Link href='/faq' passHref>
<a className='text-dark d-inline-flex'> <a className='text-dark d-inline-flex'>
FAQ FAQ
</a> </a>
</Link> </Link>
<span className='text-muted mx-2'> \ </span> <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> <small>
<a className='text-dark d-inline-flex' href='https://github.com/stackernews/stacker.news'> <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' /> This is free open source software <Github width={20} height={20} className='mx-1' />

View File

@ -89,8 +89,8 @@ export function MarkdownInput ({ label, groupClassName, ...props }) {
{...props} {...props}
/> />
</div> </div>
<div className='form-group'> <div className={tab !== 'preview' ? 'd-none' : 'form-group'}>
<div className={tab !== 'preview' ? 'd-none' : `${styles.text} form-control`}> <div className={`${styles.text} form-control`}>
<Text>{meta.value}</Text> <Text>{meta.value}</Text>
</div> </div>
</div> </div>
@ -110,7 +110,7 @@ function FormGroup ({ className, label, children }) {
function InputInner ({ prepend, append, hint, showValid, ...props }) { function InputInner ({ prepend, append, hint, showValid, ...props }) {
const [field, meta] = props.readOnly ? [{}, {}] : useField(props) const [field, meta] = props.readOnly ? [{}, {}] : useField(props)
const formik = props.readOnly ? null : useFormikContext()
return ( return (
<> <>
<InputGroup hasValidation> <InputGroup hasValidation>
@ -120,6 +120,11 @@ function InputInner ({ prepend, append, hint, showValid, ...props }) {
</InputGroup.Prepend> </InputGroup.Prepend>
)} )}
<BootstrapForm.Control <BootstrapForm.Control
onKeyDown={(e) => {
if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) {
formik?.submitForm()
}
}}
{...field} {...props} {...field} {...props}
isInvalid={meta.touched && meta.error} isInvalid={meta.touched && meta.error}
isValid={showValid && meta.touched && !meta.error} isValid={showValid && meta.touched && !meta.error}

View File

@ -14,7 +14,7 @@ import { useEffect } from 'react'
import { randInRange } from '../lib/rand' import { randInRange } from '../lib/rand'
function WalletSummary ({ me }) { function WalletSummary ({ me }) {
return `[${me.stacked},${me.sats}]` return `${me.sats} \\ ${me.stacked}`
} }
export default function Header () { export default function Header () {
@ -32,20 +32,18 @@ export default function Header () {
if (session) { if (session) {
return ( return (
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
{me && me.hasNewNotes &&
<Head> <Head>
<link rel='shortcut icon' href='/favicon-notify.png' /> <link rel='shortcut icon' href={me && me.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
</Head>} </Head>
<div className='position-relative'> <div className='position-relative mr-1'>
<NavDropdown className='pl-0' title={`@${session.user.name}`} alignRight> <NavDropdown className='px-0' title={`@${session.user.name}`} alignRight>
<Link href={'/' + session.user.name} passHref> <Link href={'/' + session.user.name} passHref>
<NavDropdown.Item>profile</NavDropdown.Item> <NavDropdown.Item>profile</NavDropdown.Item>
</Link> </Link>
<Link href='/notifications' passHref> <Link href='/notifications' passHref>
<NavDropdown.Item onClick={() => { <NavDropdown.Item onClick={() => {
// when it's a fresh click evict old notification cache // 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: 'notifications' })
client.cache.evict({ id: 'ROOT_QUERY', fieldName: 'recentlyStacked' })
}} }}
> >
notifications notifications
@ -83,7 +81,7 @@ export default function Header () {
{me && {me &&
<Nav.Item> <Nav.Item>
<Link href='/wallet' passHref> <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> </Link>
</Nav.Item>} </Nav.Item>}
</div> </div>

View File

@ -2,8 +2,16 @@ import Link from 'next/link'
import styles from './item.module.css' import styles from './item.module.css'
import { timeSince } from '../lib/time' import { timeSince } from '../lib/time'
import UpVote from './upvote' import UpVote from './upvote'
import { useMe } from './me'
import { useState } from 'react'
import Countdown from './countdown'
export default function Item ({ item, rank, children }) { 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 ( return (
<> <>
{rank {rank
@ -43,6 +51,22 @@ export default function Item ({ item, rank, children }) {
<span> </span> <span> </span>
<span>{timeSince(new Date(item.createdAt))}</span> <span>{timeSince(new Date(item.createdAt))}</span>
</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> </div>
</div> </div>

View File

@ -53,7 +53,6 @@
.skeleton .other { .skeleton .other {
height: 17px; height: 17px;
align-items: center; align-items: center;
display: flex;
} }
.skeleton .title { .skeleton .title {

View File

@ -17,7 +17,7 @@ export default function Layout ({ noContain, noFooter, noSeo, children }) {
{noContain {noContain
? children ? children
: ( : (
<Container className='mt-1 px-sm-0'> <Container className='px-sm-0'>
{children} {children}
</Container> </Container>
)} )}

100
components/link-form.js Normal file
View File

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

127
components/notifications.js Normal file
View File

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

View File

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

View File

@ -47,7 +47,7 @@ export default function Reply ({ parentId, onSuccess, autoFocus }) {
) )
return ( return (
<div className={styles.reply}> <div className={`${styles.reply} mb-1`}>
<Form <Form
initial={{ initial={{
text: '' text: ''

View File

@ -7,7 +7,7 @@ export default function Seo ({ item, user }) {
const pathNoQuery = router.asPath.split('?')[0] const pathNoQuery = router.asPath.split('?')[0]
const defaultTitle = pathNoQuery.slice(1) const defaultTitle = pathNoQuery.slice(1)
let fullTitle = `${defaultTitle && `${defaultTitle} \\ `}stacker news` 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) {
if (item.title) { if (item.title) {
fullTitle = `${item.title} \\ stacker news` fullTitle = `${item.title} \\ stacker news`

View File

@ -36,7 +36,7 @@ export default function UpVote ({ itemId, meSats, className }) {
return ( return (
<LightningConsumer> <LightningConsumer>
{({ strike }) => {({ strike }) =>
<ActionTooltip> <ActionTooltip notForm>
<UpArrow <UpArrow
width={24} width={24}
height={24} height={24}

View File

@ -31,7 +31,7 @@ export default function UserHeader ({ user }) {
const client = useApolloClient() const client = useApolloClient()
const [setName] = useMutation(NAME_MUTATION) 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({ const UserSchema = Yup.object({
name: Yup.string() name: Yup.string()

View File

@ -8,6 +8,7 @@ export const COMMENT_FIELDS = gql`
text text
user { user {
name name
id
} }
sats sats
boost boost

View File

@ -9,6 +9,7 @@ export const ITEM_FIELDS = gql`
url url
user { user {
name name
id
} }
sats sats
boost boost

View File

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

47
lib/rss.js Normal file
View File

@ -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 '&lt;'
case '>': return '&gt;'
case '&': return '&amp;'
case '\'': return '&apos;'
case '"': return '&quot;'
}
})
}
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>
`
}

21
package-lock.json generated
View File

@ -28,6 +28,7 @@
"qrcode.react": "^1.0.1", "qrcode.react": "^1.0.1",
"react": "17.0.1", "react": "17.0.1",
"react-bootstrap": "^1.5.2", "react-bootstrap": "^1.5.2",
"react-countdown": "^2.3.2",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"react-markdown": "^6.0.2", "react-markdown": "^6.0.2",
"react-syntax-highlighter": "^15.4.3", "react-syntax-highlighter": "^15.4.3",
@ -8552,6 +8553,18 @@
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
"integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" "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": { "node_modules/react-dom": {
"version": "17.0.1", "version": "17.0.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.1.tgz", "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": { "react-dom": {
"version": "17.0.1", "version": "17.0.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.1.tgz",

View File

@ -3,10 +3,10 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "NODE_OPTIONS='--trace-warnings' next dev",
"build": "next build", "build": "next build",
"migrate": "prisma migrate deploy", "migrate": "prisma migrate deploy",
"start": "next start -p $PORT" "start": "NODE_OPTIONS='--trace-warnings' next start -p $PORT"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.3.13", "@apollo/client": "^3.3.13",
@ -30,6 +30,7 @@
"qrcode.react": "^1.0.1", "qrcode.react": "^1.0.1",
"react": "17.0.1", "react": "17.0.1",
"react-bootstrap": "^1.5.2", "react-bootstrap": "^1.5.2",
"react-countdown": "^2.3.2",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"react-markdown": "^6.0.2", "react-markdown": "^6.0.2",
"react-syntax-highlighter": "^15.4.3", "react-syntax-highlighter": "^15.4.3",

View File

@ -39,7 +39,7 @@ export default function UserComments ({ user }) {
<Layout noSeo> <Layout noSeo>
<Seo user={user} /> <Seo user={user} />
<UserHeader user={user} /> <UserHeader user={user} />
<CommentsFlat variables={{ userId: user.id }} includeParent noReply clickToContext /> <CommentsFlat variables={{ userId: user.id }} includeParent noReply />
</Layout> </Layout>
) )
} }

View File

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

View File

@ -4,8 +4,13 @@ import path from 'path'
export default async function handler (req, res) { export default async function handler (req, res) {
const url = process.env.SELF_URL + '/' + path.join(...(req.query.path || [])) const url = process.env.SELF_URL + '/' + path.join(...(req.query.path || []))
res.setHeader('Content-Type', 'image/png') res.setHeader('Content-Type', 'image/png')
try {
const streams = await new Pageres({ crop: true }) const streams = await new Pageres({ crop: true })
.src(url, ['600x300']) .src(url, ['600x300'])
.run() .run()
res.status(200).end(streams[0]) res.status(200).end(streams[0])
} catch(e) {
console.log(e)
res.status(500)
}
} }

View File

@ -5,11 +5,11 @@ import secp256k1 from 'secp256k1'
import models from '../../api/models' import models from '../../api/models'
export default async ({ query }, res) => { export default async ({ query }, res) => {
try {
const sig = Buffer.from(query.sig, 'hex') const sig = Buffer.from(query.sig, 'hex')
const k1 = Buffer.from(query.k1, 'hex') const k1 = Buffer.from(query.k1, 'hex')
const key = Buffer.from(query.key, 'hex') const key = Buffer.from(query.key, 'hex')
const signature = secp256k1.signatureImport(sig) const signature = secp256k1.signatureImport(sig)
try {
if (secp256k1.ecdsaVerify(signature, k1, key)) { if (secp256k1.ecdsaVerify(signature, k1, key)) {
await models.lnAuth.update({ where: { k1: query.k1 }, data: { pubkey: query.key } }) await models.lnAuth.update({ where: { k1: query.k1 }, data: { pubkey: query.key } })
return res.status(200).json({ status: 'OK' }) return res.status(200).json({ status: 'OK' })
@ -17,5 +17,14 @@ export default async ({ query }, res) => {
} catch (error) { } catch (error) {
console.log(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 })
} }

View File

@ -13,6 +13,12 @@ import ApolloClient from '../../api/client'
// ssr the item without comments so that we can populate metatags // ssr the item without comments so that we can populate metatags
export async function getServerSideProps ({ req, params: { id } }) { export async function getServerSideProps ({ req, params: { id } }) {
if (isNaN(id)) {
return {
notFound: true
}
}
const { error, data: { item } } = await (await ApolloClient(req)).query({ const { error, data: { item } } = await (await ApolloClient(req)).query({
query: query:
gql` gql`

44
pages/items/[id]/edit.js Normal file
View File

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

View File

@ -16,7 +16,7 @@ import { gql, useMutation, useQuery } from '@apollo/client'
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) { export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
const session = await getSession({ req }) const session = await getSession({ req })
if (session && res && session.accessToken) { if (session && res && callbackUrl) {
res.writeHead(302, { res.writeHead(302, {
Location: callbackUrl Location: callbackUrl
}) })

View File

@ -1,28 +1,10 @@
import { gql, useQuery } from '@apollo/client'
import CommentsFlat from '../components/comments-flat'
import Layout from '../components/layout' import Layout from '../components/layout'
import Notifications from '../components/notifications'
export function RecentlyStacked () { export default function NotificationPage () {
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 }) {
return ( return (
<Layout> <Layout>
<RecentlyStacked /> <Notifications />
<h6 className='text-muted'>replies</h6>
<CommentsFlat noReply includeParent clickToContext />
</Layout> </Layout>
) )
} }

View File

@ -1,124 +1,10 @@
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import Link from 'next/link' import Link from 'next/link'
import * as Yup from 'yup'
import { gql, useMutation } from '@apollo/client'
import LayoutCenter from '../components/layout-center' import LayoutCenter from '../components/layout-center'
import { ensureProtocol } from '../lib/url'
import { useMe } from '../components/me' import { useMe } from '../components/me'
import ActionTooltip from '../components/action-tooltip' import { DiscussionForm } from '../components/discussion-form'
import TextareaAutosize from 'react-textarea-autosize' import { LinkForm } from '../components/link-form'
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>
)
}
export function PostForm () { export function PostForm () {
const router = useRouter() const router = useRouter()

24
pages/rss.js Normal file
View File

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

View File

@ -7,7 +7,7 @@ import { gql, useMutation } from '@apollo/client'
import { LnQRSkeleton } from '../components/lnqr' import { LnQRSkeleton } from '../components/lnqr'
import LayoutCenter from '../components/layout-center' import LayoutCenter from '../components/layout-center'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import { WithdrawlSkeleton } from './withdrawls/[id]' import { WithdrawlSkeleton } from './withdrawals/[id]'
import { useMe } from '../components/me' import { useMe } from '../components/me'
export default function Wallet () { export default function Wallet () {
@ -38,7 +38,7 @@ export function WalletForm () {
<Button variant='success'>fund</Button> <Button variant='success'>fund</Button>
</Link> </Link>
<span className='mx-3 font-weight-bold text-muted'>or</span> <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> <Button variant='success'>withdraw</Button>
</Link> </Link>
</div> </div>
@ -129,7 +129,7 @@ export function WithdrawlForm () {
schema={WithdrawlSchema} schema={WithdrawlSchema}
onSubmit={async ({ invoice, maxFee }) => { onSubmit={async ({ invoice, maxFee }) => {
const { data } = await createWithdrawl({ variables: { invoice, maxFee: Number(maxFee) } }) const { data } = await createWithdrawl({ variables: { invoice, maxFee: Number(maxFee) } })
router.push(`/withdrawls/${data.createWithdrawl.id}`) router.push(`/withdrawals/${data.createWithdrawl.id}`)
}} }}
> >
<Input <Input
@ -144,7 +144,7 @@ export function WithdrawlForm () {
required required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} 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> </Form>
</> </>
) )

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
-- add withdrawal as user
INSERT INTO "users" ("name") VALUES ('withdrawal');

View File

@ -0,0 +1,2 @@
-- add withdrawals as user
INSERT INTO "users" ("name") VALUES ('withdrawals');

View File

@ -0,0 +1,3 @@
-- withdraw users
INSERT INTO "users" ("name") VALUES ('withdraw');
INSERT INTO "users" ("name") VALUES ('withdraws');

View File

@ -19,6 +19,7 @@ model User {
emailVerified DateTime? @map(name: "email_verified") emailVerified DateTime? @map(name: "email_verified")
image String? image String?
items Item[] items Item[]
mentions Mention[]
messages Message[] messages Message[]
votes Vote[] votes Vote[]
invoices Invoice[] invoices Invoice[]
@ -60,6 +61,7 @@ model Item {
parentId Int? parentId Int?
children Item[] @relation("ParentChildren") children Item[] @relation("ParentChildren")
votes Vote[] votes Vote[]
mentions Mention[]
path Unsupported("LTREE")? path Unsupported("LTREE")?
@@index([userId]) @@index([userId])
@ -81,6 +83,20 @@ model Vote {
@@index([userId]) @@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 { model Invoice {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at") createdAt DateTime @default(now()) @map(name: "created_at")

View File

@ -191,7 +191,7 @@ footer {
} }
.flash-it { .flash-it {
animation: flash 2s linear 2; animation: flash 2s linear 1;
} }
@keyframes spin { @keyframes spin {

View File

@ -57,7 +57,7 @@ async function checkPendingInvoices () {
const inv = await getInvoice({ id: invoice.hash, lnd }) const inv = await getInvoice({ id: invoice.hash, lnd })
await recordInvoiceStatus(inv) await recordInvoiceStatus(inv)
} catch (error) { } catch (error) {
console.log(error) console.log(invoice, error)
process.exit(1) process.exit(1)
} }
}) })
@ -106,7 +106,7 @@ async function checkPendingWithdrawls () {
const wdrwl = await getPayment({ id: withdrawl.hash, lnd }) const wdrwl = await getPayment({ id: withdrawl.hash, lnd })
await recordWithdrawlStatus(withdrawl.id, wdrwl) await recordWithdrawlStatus(withdrawl.id, wdrwl)
} catch (error) { } catch (error) {
console.log(error) console.log(withdrawl, error)
process.exit(1) process.exit(1)
} }
}) })