diff --git a/api/models/index.js b/api/models/index.js index 67d8b6c3..1312709e 100644 --- a/api/models/index.js +++ b/api/models/index.js @@ -1,6 +1,8 @@ import { PrismaClient } from '@prisma/client' -const prisma = global.prisma || new PrismaClient() +const prisma = global.prisma || new PrismaClient({ + log: ['query', 'warn', 'error'] +}) if (process.env.NODE_ENV === 'development') global.prisma = prisma diff --git a/api/resolvers/cursor.js b/api/resolvers/cursor.js new file mode 100644 index 00000000..d9cdfd63 --- /dev/null +++ b/api/resolvers/cursor.js @@ -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') +} diff --git a/api/resolvers/index.js b/api/resolvers/index.js index 484e2f7d..91a04e7b 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -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] diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 73a18b8a..6c8941cd 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -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 }) => { @@ -88,6 +72,7 @@ export default { OFFSET $3 LIMIT ${LIMIT}`, Number(userId), decodedCursor.time, decodedCursor.offset) } else { + // notifications ... god such spagetti if (!me) { throw new AuthenticationError('you must be logged in') } @@ -105,18 +90,6 @@ export default { 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} diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js new file mode 100644 index 00000000..138b9146 --- /dev/null +++ b/api/resolvers/notifications.js @@ -0,0 +1,88 @@ +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 sort_time, NULL as "earnedSats" + From "Item" + JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = $1 + AND "Item"."userId" <> $1 AND "Item".created_at <= $2 + UNION ALL + (SELECT ${ITEM_SUBQUERY_FIELDS}, max(subquery.voted_at) as sort_time, sum(subquery.sats) as "earnedSats" + 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 "Item" + LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id + AND "Vote"."userId" <> $1 + AND "Item".created_at <= $2 + AND "Vote".boost = false + WHERE "Item"."userId" = $1) subquery + GROUP BY ${ITEM_SUBQUERY_FIELDS}, subquery.island ORDER BY max(subquery.voted_at) desc) + ORDER BY sort_time DESC + OFFSET $3 + LIMIT ${LIMIT}`, me ? me.id : 622, decodedCursor.time, decodedCursor.offset) + + notifications = notifications.map(n => { + n.item = { ...n } + return n + }) + return { + cursor: notifications.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + notifications + } + } + }, + Notification: { + __resolveType: async (notification, args, { models }) => + notification.earnedSats ? 'Votification' : '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"` diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index 025546b9..093272c7 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -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] diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 6b01d42f..99a4f111 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -4,7 +4,6 @@ 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!] } diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js new file mode 100644 index 00000000..1374c5c2 --- /dev/null +++ b/api/typeDefs/notifications.js @@ -0,0 +1,23 @@ +import { gql } from 'apollo-server-micro' + +export default gql` + extend type Query { + notifications(cursor: String): Notifications + } + + type Votification { + earnedSats: Int! + item: Item! + } + + type Reply { + item: Item! + } + + union Notification = Reply | Votification + + type Notifications { + cursor: String + notifications: [Notification!]! + } +` diff --git a/components/notifications.js b/components/notifications.js new file mode 100644 index 00000000..58a9314f --- /dev/null +++ b/components/notifications.js @@ -0,0 +1,68 @@ +import { useQuery } from '@apollo/client' +import Button from 'react-bootstrap/Button' +import { useState } from 'react' +import Comment, { CommentSkeleton } from './comment' +import { NOTIFICATIONS } from '../fragments/notifications' + +export default function CommentsFlat ({ variables, ...props }) { + const { loading, error, data, fetchMore } = useQuery(NOTIFICATIONS) + if (error) return
Failed to load!
+ if (loading) { + return + } + + const { notifications: { notifications, cursor } } = data + return ( + <> + {notifications.map(item => ( + + ))} + + + ) +} + +function CommentsFlatSkeleton () { + const comments = new Array(21).fill(null) + + return ( +
{comments.map((_, i) => ( + + ))} +
+ ) +} + +function MoreFooter ({ cursor, fetchMore }) { + const [loading, setLoading] = useState(false) + + if (loading) { + return
+ } + + let Footer + if (cursor) { + Footer = () => ( + + ) + } else { + Footer = () => ( +
GENISIS
+ ) + } + + return
+} diff --git a/components/seo.js b/components/seo.js index 543dee1d..19d4bd9c 100644 --- a/components/seo.js +++ b/components/seo.js @@ -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` diff --git a/fragments/notifications.js b/fragments/notifications.js new file mode 100644 index 00000000..5e755867 --- /dev/null +++ b/fragments/notifications.js @@ -0,0 +1,27 @@ +import { gql } from 'apollo-server-micro' +import { ITEM_FIELDS } from './items' + +export const NOTIFICATIONS = gql` + ${ITEM_FIELDS} + + query Notifications($cursor: String) { + notifications(cursor: $cursor) { + cursor + notifications { + __typename + ... on Votification { + earnedSats + item { + ...ItemFields + text + } + } + ... on Reply { + item { + ...ItemFields + text + } + } + } + } + } `