From ed961b7bdf7088b84c1c67028999822a8eae0913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Bordalo?= Date: Fri, 23 Jul 2021 16:45:09 +0100 Subject: [PATCH 01/34] implements rss feed --- lib/rss.js | 31 +++++++++++++++++++++++++++++++ pages/rss.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 lib/rss.js create mode 100644 pages/rss.js diff --git a/lib/rss.js b/lib/rss.js new file mode 100644 index 00000000..d117d49f --- /dev/null +++ b/lib/rss.js @@ -0,0 +1,31 @@ +const SITE_URL = 'https://stacker.news' +const SITE_TITLE = 'Stacker News' +const SITE_SUBTITLE = 'Like Hacker News, but with sats' + +const generateRssItem = (item) => { + return ` + + ${SITE_URL}/items/${item.id} + ${item.title} + ${SITE_URL}/items/${item.id} + ${new Date(item.createdAt).toUTCString()} + + ` +} + +export default function generateRssFeed (items) { + const itemsList = items.map(generateRssItem) + return ` + + + ${SITE_TITLE} + ${SITE_URL} + ${SITE_SUBTITLE} + en + ${new Date().toUTCString()} + + ${itemsList.join('')} + + + ` +} diff --git a/pages/rss.js b/pages/rss.js new file mode 100644 index 00000000..9706b350 --- /dev/null +++ b/pages/rss.js @@ -0,0 +1,31 @@ + +import ApolloClient from '../api/client' +import { gql } from '@apollo/client' +import generateRssFeed from '../lib/rss' + +export default function RssFeed () { + return null +} + +export async function getServerSideProps({ req, res }) { + const emptyProps = { props: {} } // to avoid server side warnings + const { error, data } = await (await ApolloClient(req)).query({ + query: gql` + query Items { + items { + createdAt + id + title + } + } + `, + }) + + if (!data.items || error) return emptyProps + + res.setHeader("Content-Type", "text/xml") + res.write(generateRssFeed(data.items)) + res.end() + + return emptyProps +} From f92b36699a6660795cb0e0e577bc1fed6a1d8e88 Mon Sep 17 00:00:00 2001 From: keyan Date: Mon, 9 Aug 2021 14:47:39 -0500 Subject: [PATCH 02/34] fix graphql query for rss --- pages/rss.js | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/pages/rss.js b/pages/rss.js index 9706b350..7276a7da 100644 --- a/pages/rss.js +++ b/pages/rss.js @@ -1,30 +1,23 @@ import ApolloClient from '../api/client' -import { gql } from '@apollo/client' import generateRssFeed from '../lib/rss' +import { MORE_ITEMS } from '../fragments/items' export default function RssFeed () { return null } -export async function getServerSideProps({ req, res }) { +export async function getServerSideProps ({ req, res }) { const emptyProps = { props: {} } // to avoid server side warnings - const { error, data } = await (await ApolloClient(req)).query({ - query: gql` - query Items { - items { - createdAt - id - title - } - } - `, + const { error, data: { moreItems: { items } } } = await (await ApolloClient(req)).query({ + query: MORE_ITEMS, + variables: { sort: 'hot' } }) - if (!data.items || error) return emptyProps + if (!items || error) return emptyProps - res.setHeader("Content-Type", "text/xml") - res.write(generateRssFeed(data.items)) + res.setHeader('Content-Type', 'text/xml') + res.write(generateRssFeed(items)) res.end() return emptyProps From 72c214bf29608f8246c9297c3824a9c4333e3b53 Mon Sep 17 00:00:00 2001 From: keyan Date: Mon, 9 Aug 2021 14:50:56 -0500 Subject: [PATCH 03/34] add rss link to footer --- components/footer.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/footer.js b/components/footer.js index 3cf7fdf0..3062e4b3 100644 --- a/components/footer.js +++ b/components/footer.js @@ -38,6 +38,12 @@ export default function Footer () { \ + + + RSS + + + \ This is free open source software From bee1ffe10bfd5c0c16adf29208d03623d63c7e59 Mon Sep 17 00:00:00 2001 From: keyan Date: Mon, 9 Aug 2021 15:12:13 -0500 Subject: [PATCH 04/34] don't use react router for rss --- components/footer.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/components/footer.js b/components/footer.js index 3062e4b3..01cababa 100644 --- a/components/footer.js +++ b/components/footer.js @@ -38,11 +38,9 @@ export default function Footer () { \ - - - RSS - - + + RSS + \ From a38ef84b5dd775394b02ec01a2e354e5518678cb Mon Sep 17 00:00:00 2001 From: keyan Date: Mon, 9 Aug 2021 15:52:41 -0500 Subject: [PATCH 05/34] escape XML specials and other RSS refinements --- lib/rss.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/rss.js b/lib/rss.js index d117d49f..ae1e3b26 100644 --- a/lib/rss.js +++ b/lib/rss.js @@ -1,12 +1,24 @@ const SITE_URL = 'https://stacker.news' const SITE_TITLE = 'Stacker News' -const SITE_SUBTITLE = 'Like Hacker News, but with sats' +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) => { return ` ${SITE_URL}/items/${item.id} - ${item.title} + ${escapeXml(item.title)} ${SITE_URL}/items/${item.id} ${new Date(item.createdAt).toUTCString()} @@ -16,14 +28,14 @@ const generateRssItem = (item) => { export default function generateRssFeed (items) { const itemsList = items.map(generateRssItem) return ` - + ${SITE_TITLE} ${SITE_URL} ${SITE_SUBTITLE} en ${new Date().toUTCString()} - + ${SITE_URL} ${itemsList.join('')} From fec2a6ecc06b230c51ced990a11d5e191187a273 Mon Sep 17 00:00:00 2001 From: keyan Date: Mon, 9 Aug 2021 16:15:27 -0500 Subject: [PATCH 06/34] open analytics link in footer --- components/footer.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/footer.js b/components/footer.js index 01cababa..9cb88e7f 100644 --- a/components/footer.js +++ b/components/footer.js @@ -38,10 +38,14 @@ export default function Footer () { \ - + RSS \ + + Analytics + + \ This is free open source software From b4be2c613b1adc1e1cba1a476756ceff2d969fe5 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 10 Aug 2021 17:59:06 -0500 Subject: [PATCH 07/34] comment edit spagetti --- api/resolvers/item.js | 26 +++++- api/typeDefs/item.js | 1 + components/comment-edit.js | 79 +++++++++++++++++++ components/comment.js | 71 ++++++++++++++--- components/comment.module.css | 3 +- components/form.js | 4 +- components/item.module.css | 1 - components/reply.js | 2 +- fragments/comments.js | 1 + fragments/items.js | 1 + package-lock.json | 21 +++++ package.json | 1 + .../migration.sql | 18 +++++ 13 files changed, 212 insertions(+), 17 deletions(-) create mode 100644 components/comment-edit.js create mode 100644 prisma/migrations/20210810195449_fix_item_path_trigger/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 65b4fcff..005abedb 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -159,11 +159,35 @@ export default { } 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 must belong to you') + } + + if (Date.now() > new Date(comment.createdAt).getTime() + 10 * 60000) { + throw new UserInputError('comment can no longer be editted') + } + + return await models.item.update({ + where: { id: Number(id) }, + data: { text } + }) + }, vote: async (parent, { id, sats = 1 }, { me, models }) => { // need to make sure we are logged in if (!me) { diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index c5d924cf..08980f17 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -13,6 +13,7 @@ export default gql` createLink(title: String!, url: String): Item! createDiscussion(title: String!, text: String): Item! createComment(text: String!, parentId: ID!): Item! + updateComment(id: ID!, text: String!): Item! vote(id: ID!, sats: Int): Int! } diff --git a/components/comment-edit.js b/components/comment-edit.js new file mode 100644 index 00000000..3bdc2b52 --- /dev/null +++ b/components/comment-edit.js @@ -0,0 +1,79 @@ +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 'react-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 ( +
+
{ + const { error } = await updateComment({ variables: { ...values, id: comment.id } }) + if (error) { + throw new Error({ message: error.toString() }) + } + if (onSuccess) { + onSuccess() + } + }} + > + + {props.formatted.minutes}:{props.formatted.seconds}} + /> + + } + /> +
+ save +
+ cancel +
+
+ +
+ ) +} diff --git a/components/comment.js b/components/comment.js index 56b2fd91..21f1069d 100644 --- a/components/comment.js +++ b/components/comment.js @@ -9,6 +9,9 @@ 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 'react-countdown' function Parent ({ item }) { const ParentFrag = () => ( @@ -37,9 +40,15 @@ function Parent ({ item }) { export default function Comment ({ item, children, replyOpen, includeParent, cacheId, noComments, noReply, clickToContext }) { 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)) { @@ -81,23 +90,63 @@ export default function Comment ({ item, children, replyOpen, includeParent, cac : setCollapse(true)} />)} -
- {item.text} -
+ {edit + ? ( +
+ { + setEdit(!edit) + setCanEdit(mine && (Date.now() < editThreshold)) + }} + onCancel={() => { + setEdit(!edit) + setCanEdit(mine && (Date.now() < editThreshold)) + }} + editThreshold={editThreshold} + /> +
+ ) + : ( +
+ {item.text} +
+ )}
- {!noReply && -
setReply(!reply)} - > - {reply ? 'cancel' : 'reply'} -
} + {!noReply && !edit && ( +
+
setReply(!reply)} + > + {reply ? 'cancel' : 'reply'} +
+ {canEdit && !reply && !edit && + <> + \ +
setEdit(!edit)} + > + edit + {props.formatted.minutes}:{props.formatted.seconds}} + onComplete={() => { + setCanEdit(false) + }} + /> +
+ } +
+ )} +
setReply(replyOpen || false)} cacheId={cacheId} + onSuccess={() => setReply(replyOpen || false)} />
{children} diff --git a/components/comment.module.css b/components/comment.module.css index aa9ed4f1..e04fd681 100644 --- a/components/comment.module.css +++ b/components/comment.module.css @@ -45,7 +45,8 @@ } .children { - margin-top: .25rem; + margin-top: 0; + padding-top: .25rem; } .comments { diff --git a/components/form.js b/components/form.js index 40210798..7879d125 100644 --- a/components/form.js +++ b/components/form.js @@ -89,8 +89,8 @@ export function MarkdownInput ({ label, groupClassName, ...props }) { {...props} />
-
-
+
+
{meta.value}
diff --git a/components/item.module.css b/components/item.module.css index b3283cff..112b857f 100644 --- a/components/item.module.css +++ b/components/item.module.css @@ -53,7 +53,6 @@ .skeleton .other { height: 17px; align-items: center; - display: flex; } .skeleton .title { diff --git a/components/reply.js b/components/reply.js index 93886d82..8ba87042 100644 --- a/components/reply.js +++ b/components/reply.js @@ -47,7 +47,7 @@ export default function Reply ({ parentId, onSuccess, autoFocus }) { ) return ( -
+
= 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", diff --git a/package.json b/package.json index 90f2a4a1..4c233de1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20210810195449_fix_item_path_trigger/migration.sql b/prisma/migrations/20210810195449_fix_item_path_trigger/migration.sql new file mode 100644 index 00000000..ae2b313c --- /dev/null +++ b/prisma/migrations/20210810195449_fix_item_path_trigger/migration.sql @@ -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; From fd79b92b9bce6fbb2f2f2079af85a3a20ece76f7 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 10 Aug 2021 18:09:27 -0500 Subject: [PATCH 08/34] fix comment editting if not logged in --- components/comment.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/comment.js b/components/comment.js index 21f1069d..f5d71724 100644 --- a/components/comment.js +++ b/components/comment.js @@ -45,7 +45,7 @@ export default function Comment ({ item, children, replyOpen, includeParent, cac const ref = useRef(null) const router = useRouter() const me = useMe() - const mine = me.id === item.user.id + const mine = me?.id === item.user.id const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 const [canEdit, setCanEdit] = useState(mine && (Date.now() < editThreshold)) From a48cd33db395f548086cb31a7a46493212350ff6 Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 11 Aug 2021 15:13:10 -0500 Subject: [PATCH 09/34] edit posts - links and discussions --- api/resolvers/item.js | 56 +++++++++++++++- api/typeDefs/item.js | 2 + components/discussion-form.js | 93 +++++++++++++++++++++++++++ components/item.js | 24 +++++++ components/link-form.js | 107 ++++++++++++++++++++++++++++++ pages/items/[id]/edit.js | 44 +++++++++++++ pages/post.js | 118 +--------------------------------- 7 files changed, 326 insertions(+), 118 deletions(-) create mode 100644 components/discussion-form.js create mode 100644 components/link-form.js create mode 100644 pages/items/[id]/edit.js diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 005abedb..a9bd7170 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -146,13 +146,65 @@ 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 models.item.update({ + where: { id: Number(id) }, + data: { title, url: ensureProtocol(url) } + }) + }, + 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 models.item.update({ + where: { id: Number(id) }, + data: { title, text } + }) + }, createComment: async (parent, { text, parentId }, { me, models }) => { if (!text) { throw new UserInputError('comment must have text', { argumentName: 'text' }) @@ -176,7 +228,7 @@ export default { // 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 must belong to you') + throw new AuthenticationError('comment does not belong to you') } if (Date.now() > new Date(comment.createdAt).getTime() + 10 * 60000) { diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 08980f17..6b01d42f 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -11,7 +11,9 @@ export default gql` 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! diff --git a/components/discussion-form.js b/components/discussion-form.js new file mode 100644 index 00000000..e8617331 --- /dev/null +++ b/components/discussion-form.js @@ -0,0 +1,93 @@ +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 'react-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 ( + { + 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}`) + }} + > + + text optional} + name='text' + as={TextareaAutosize} + minRows={4} + hint={editThreshold + ? ( + + {props.formatted.minutes}:{props.formatted.seconds}} + /> + + ) + : null} + /> + + {item ? 'save' : 'post'} + + + ) +} diff --git a/components/item.js b/components/item.js index a1e1a3b9..77c66f62 100644 --- a/components/item.js +++ b/components/item.js @@ -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 'react-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 }) { {timeSince(new Date(item.createdAt))} + {canEdit && + <> + \ + +
+ edit + {props.formatted.minutes}:{props.formatted.seconds}} + onComplete={() => { + setCanEdit(false) + }} + /> + + + }
diff --git a/components/link-form.js b/components/link-form.js new file mode 100644 index 00000000..d50bdf22 --- /dev/null +++ b/components/link-form.js @@ -0,0 +1,107 @@ +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 'react-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 ( +
{ + 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}`) + }} + > + + + {props.formatted.minutes}:{props.formatted.seconds}} + /> + + ) + : null} + /> + + {item ? 'save' : 'post'} + + + ) +} diff --git a/pages/items/[id]/edit.js b/pages/items/[id]/edit.js new file mode 100644 index 00000000..f84699dd --- /dev/null +++ b/pages/items/[id]/edit.js @@ -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 ( + + {item.url + ? + : } + + ) +} diff --git a/pages/post.js b/pages/post.js index f7f50bc3..b8e7e407 100644 --- a/pages/post.js +++ b/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 ( -
{ - const { data: { createDiscussion: { id } }, error } = await createDiscussion({ variables: values }) - if (error) { - throw new Error({ message: error.toString() }) - } - router.push(`items/${id}`) - }} - > - - text optional} - name='text' - as={TextareaAutosize} - minRows={4} - /> - - post - - - ) -} - -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 ( -
{ - const { data: { createLink: { id } }, error } = await createLink({ variables: values }) - if (error) { - throw new Error({ message: error.toString() }) - } - router.push(`items/${id}`) - }} - > - - - - post - -
- ) -} +import { DiscussionForm } from '../components/discussion-form' +import { LinkForm } from '../components/link-form' export function PostForm () { const router = useRouter() From 9fce2154f6085f2916e6e21368ead283a482277a Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 11 Aug 2021 15:34:10 -0500 Subject: [PATCH 10/34] reuse formatted countdown component --- components/comment-edit.js | 11 ++--------- components/comment.js | 4 ++-- components/countdown.js | 13 +++++++++++++ components/discussion-form.js | 11 ++--------- components/item.js | 4 ++-- components/link-form.js | 11 ++--------- 6 files changed, 23 insertions(+), 31 deletions(-) create mode 100644 components/countdown.js diff --git a/components/comment-edit.js b/components/comment-edit.js index 3bdc2b52..f261bf3f 100644 --- a/components/comment-edit.js +++ b/components/comment-edit.js @@ -3,7 +3,7 @@ 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 'react-countdown' +import Countdown from '../components/countdown' export const CommentSchema = Yup.object({ text: Yup.string().required('required').trim() @@ -54,14 +54,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc autoFocus required groupClassName='mb-0' - hint={ - - {props.formatted.minutes}:{props.formatted.seconds}} - /> - - } + hint={} />
save diff --git a/components/comment.js b/components/comment.js index f5d71724..edd58068 100644 --- a/components/comment.js +++ b/components/comment.js @@ -11,7 +11,7 @@ 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 'react-countdown' +import Countdown from './countdown' function Parent ({ item }) { const ParentFrag = () => ( @@ -133,7 +133,7 @@ export default function Comment ({ item, children, replyOpen, includeParent, cac edit {props.formatted.minutes}:{props.formatted.seconds}} + className=' ' onComplete={() => { setCanEdit(false) }} diff --git a/components/countdown.js b/components/countdown.js new file mode 100644 index 00000000..3c09bff2 --- /dev/null +++ b/components/countdown.js @@ -0,0 +1,13 @@ +import Countdown from 'react-countdown' + +export default function SimpleCountdown ({ className, onComplete, date }) { + return ( + + {props.formatted.minutes}:{props.formatted.seconds}} + onComplete={onComplete} + /> + + ) +} diff --git a/components/discussion-form.js b/components/discussion-form.js index e8617331..612a8eef 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -4,7 +4,7 @@ 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 'react-countdown' +import Countdown from './countdown' export const DiscussionSchema = Yup.object({ title: Yup.string().required('required').trim() @@ -75,14 +75,7 @@ export function DiscussionForm ({ item, editThreshold }) { as={TextareaAutosize} minRows={4} hint={editThreshold - ? ( - - {props.formatted.minutes}:{props.formatted.seconds}} - /> - - ) + ? : null} /> diff --git a/components/item.js b/components/item.js index 77c66f62..32d7a221 100644 --- a/components/item.js +++ b/components/item.js @@ -4,7 +4,7 @@ import { timeSince } from '../lib/time' import UpVote from './upvote' import { useMe } from './me' import { useState } from 'react' -import Countdown from 'react-countdown' +import Countdown from './countdown' export default function Item ({ item, rank, children }) { const me = useMe() @@ -59,7 +59,7 @@ export default function Item ({ item, rank, children }) { edit {props.formatted.minutes}:{props.formatted.seconds}} + className=' ' onComplete={() => { setCanEdit(false) }} diff --git a/components/link-form.js b/components/link-form.js index d50bdf22..486c8b88 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -4,7 +4,7 @@ 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 'react-countdown' +import Countdown from './countdown' export const LinkSchema = Yup.object({ title: Yup.string().required('required').trim(), @@ -89,14 +89,7 @@ export function LinkForm ({ item, editThreshold }) { name='url' required hint={editThreshold - ? ( - - {props.formatted.minutes}:{props.formatted.seconds}} - /> - - ) + ? : null} /> From 89b8e391c916bd8af8dec1ef38a06a02b00c47fe Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 11 Aug 2021 15:40:18 -0500 Subject: [PATCH 11/34] only display extra footer links on homepage --- components/footer.js | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/components/footer.js b/components/footer.js index 9cb88e7f..94aa29a3 100644 --- a/components/footer.js +++ b/components/footer.js @@ -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,20 +34,23 @@ export default function Footer () { placeholder={data.connectAddress} />
} - - - FAQ - - - \ - - RSS - - \ - - Analytics - - \ + {router.asPath === '/' && + <> + + + FAQ + + + \ + + RSS + + \ + + Analytics + + \ + } This is free open source software From 1d6e301b105691538c94fae57a5108bb84b0566b Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 12 Aug 2021 16:21:56 -0500 Subject: [PATCH 12/34] fix crashes and report which object failed in walletd --- api/resolvers/item.js | 4 +++- pages/api/capture/[[...path]].js | 9 +++++++-- pages/items/[id].js | 6 ++++++ walletd/index.js | 4 ++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index a9bd7170..73a18b8a 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -122,7 +122,9 @@ export default { ${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 }) => { diff --git a/pages/api/capture/[[...path]].js b/pages/api/capture/[[...path]].js index 042983dc..3a5968bb 100644 --- a/pages/api/capture/[[...path]].js +++ b/pages/api/capture/[[...path]].js @@ -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) + } } diff --git a/pages/items/[id].js b/pages/items/[id].js index 24b279ec..2993d5ec 100644 --- a/pages/items/[id].js +++ b/pages/items/[id].js @@ -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` diff --git a/walletd/index.js b/walletd/index.js index 00b02aff..49c6e6de 100644 --- a/walletd/index.js +++ b/walletd/index.js @@ -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) } }) From ed6a683b792dea881a2254c37eaed97486b23c3c Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 12 Aug 2021 18:25:19 -0500 Subject: [PATCH 13/34] reject 0 amount withdrawals --- api/resolvers/wallet.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 7d89e6fd..2ab95caf 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -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) From 8a054d55ff379aeb2cadf2c720f449889d9b9bb2 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 12 Aug 2021 18:46:15 -0500 Subject: [PATCH 14/34] provide more desc error in lnurl-auth --- pages/api/lnauth.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/pages/api/lnauth.js b/pages/api/lnauth.js index 75866ef3..bbabb66e 100644 --- a/pages/api/lnauth.js +++ b/pages/api/lnauth.js @@ -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 }) } From f27aca546d0a52c753f0e53d90758cdb3b71436c Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 12 Aug 2021 18:48:27 -0500 Subject: [PATCH 15/34] make callback url on login great again --- pages/login.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/login.js b/pages/login.js index 0207b341..b62af99d 100644 --- a/pages/login.js +++ b/pages/login.js @@ -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 }) From 5ad70efbd7859376bb8c0b6683214cf39d27ffac Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 13 Aug 2021 16:12:19 -0500 Subject: [PATCH 16/34] attempt at fixing 1 sat tooltip glitch --- components/action-tooltip.js | 9 ++++++++- components/upvote.js | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/components/action-tooltip.js b/components/action-tooltip.js index 5de6ae62..9421ee8e 100644 --- a/components/action-tooltip.js +++ b/components/action-tooltip.js @@ -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 ( } trigger={['hover', 'focus']} + show={formik?.isSubmitting ? false : undefined} > {children} diff --git a/components/upvote.js b/components/upvote.js index 8f7ca981..96756b06 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -36,7 +36,7 @@ export default function UpVote ({ itemId, meSats, className }) { return ( {({ strike }) => - + Date: Tue, 17 Aug 2021 13:15:24 -0500 Subject: [PATCH 17/34] query is working --- api/models/index.js | 4 +- api/resolvers/cursor.js | 16 +++++++ api/resolvers/index.js | 3 +- api/resolvers/item.js | 31 +----------- api/resolvers/notifications.js | 88 ++++++++++++++++++++++++++++++++++ api/typeDefs/index.js | 3 +- api/typeDefs/item.js | 1 - api/typeDefs/notifications.js | 23 +++++++++ components/notifications.js | 68 ++++++++++++++++++++++++++ components/seo.js | 2 +- fragments/notifications.js | 27 +++++++++++ 11 files changed, 232 insertions(+), 34 deletions(-) create mode 100644 api/resolvers/cursor.js create mode 100644 api/resolvers/notifications.js create mode 100644 api/typeDefs/notifications.js create mode 100644 components/notifications.js create mode 100644 fragments/notifications.js 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 + } + } + } + } + } ` From c8df41bfa548b8cf4d21f0e6922b4e0deab320d1 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 17 Aug 2021 18:07:52 -0500 Subject: [PATCH 18/34] fix clickToContext issue for comments, fix non-inner joins, make notification query work --- api/models/index.js | 2 +- api/resolvers/notifications.js | 16 +++++++++------- api/resolvers/user.js | 24 +++++++++++++----------- api/typeDefs/item.js | 1 + components/comment.js | 18 +++++++++++++----- components/notifications.js | 20 +++++++++++++++----- fragments/notifications.js | 2 +- pages/_app.js | 18 ++++++++++++++++++ pages/notifications.js | 24 +++--------------------- 9 files changed, 74 insertions(+), 51 deletions(-) diff --git a/api/models/index.js b/api/models/index.js index 1312709e..e79851f2 100644 --- a/api/models/index.js +++ b/api/models/index.js @@ -1,7 +1,7 @@ import { PrismaClient } from '@prisma/client' const prisma = global.prisma || new PrismaClient({ - log: ['query', 'warn', 'error'] + log: ['warn', 'error'] }) if (process.env.NODE_ENV === 'development') global.prisma = prisma diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 138b9146..eac7a6bb 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -1,3 +1,4 @@ +import { AuthenticationError } from 'apollo-server-micro' import { decodeCursor, LIMIT, nextCursorEncoded } from './cursor' export default { @@ -44,7 +45,8 @@ export default { 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 + 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 sort_time, sum(subquery.sats) as "earnedSats" @@ -52,16 +54,16 @@ export default { (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 + FROM "Vote" + JOIN "Item" on "Vote"."itemId" = "Item".id + WHERE "Vote"."userId" <> $1 AND "Item".created_at <= $2 AND "Vote".boost = false - WHERE "Item"."userId" = $1) subquery + AND "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) + LIMIT ${LIMIT}`, 622, decodedCursor.time, decodedCursor.offset) notifications = notifications.map(n => { n.item = { ...n } @@ -85,4 +87,4 @@ const ITEM_SUBQUERY_FIELDS = 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"` + "Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path") AS path` diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 9a198482..92f84d13 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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,7 +92,8 @@ 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 diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 99a4f111..c01a8378 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -44,5 +44,6 @@ export default gql` meSats: Int! ncomments: Int! comments: [Item!]! + path: String } ` diff --git a/components/comment.js b/components/comment.js index edd58068..5620cb2b 100644 --- a/components/comment.js +++ b/components/comment.js @@ -13,7 +13,7 @@ import { useMe } from './me' import CommentEdit from './comment-edit' import Countdown from './countdown' -function Parent ({ item }) { +function Parent ({ item, rootText }) { const ParentFrag = () => ( <> \ @@ -32,13 +32,13 @@ function Parent ({ item }) { {Number(item.root.id) !== Number(item.parentId) && } \ -
e.stopPropagation()} className='text-reset'>root: {item.root.title} + e.stopPropagation()} className='text-reset'>{rootText || 'on:'} {item.root.title} ) } -export default function Comment ({ item, children, replyOpen, includeParent, cacheId, noComments, noReply, clickToContext }) { +export default function Comment ({ item, children, replyOpen, includeParent, rootText, noComments, noReply, clickToContext }) { const [reply, setReply] = useState(replyOpen) const [edit, setEdit] = useState() const [collapse, setCollapse] = useState(false) @@ -50,8 +50,11 @@ export default function Comment ({ item, children, replyOpen, includeParent, cac const [canEdit, setCanEdit] = useState(mine && (Date.now() < editThreshold)) + console.log('wtf router', router, item.id, ref.current) + useEffect(() => { if (Number(router.query.commentId) === Number(item.id)) { + console.log(ref.current.scrollTop) ref.current.scrollIntoView() // ref.current.classList.add('flash-it') } @@ -61,7 +64,12 @@ export default function Comment ({ item, children, replyOpen, includeParent, cac
{ if (clickToContext) { - router.push(`/items/${item.parentId}?commentId=${item.id}`, `/items/${item.parentId}`) + console.log('pushing') + // router.push(`/items/${item.parentId}?commentId=${item.id}`, `/items/${item.parentId}`, { scroll: false }) + router.push({ + pathname: '/items/[id]', + query: { id: item.parentId, commentId: item.id } + }, `/items/${item.parentId}`) } }} className={includeParent ? `${clickToContext ? styles.clickToContext : ''}` : `${styles.comment} ${collapse ? styles.collapsed : ''}`} > @@ -83,7 +91,7 @@ export default function Comment ({ item, children, replyOpen, includeParent, cac {timeSince(new Date(item.createdAt))} - {includeParent && } + {includeParent && }
{!includeParent && (collapse ? setCollapse(false)} /> diff --git a/components/notifications.js b/components/notifications.js index 58a9314f..ebce1242 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -2,20 +2,30 @@ import { 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' -export default function CommentsFlat ({ variables, ...props }) { - const { loading, error, data, fetchMore } = useQuery(NOTIFICATIONS) +export default function Notifications ({ variables, ...props }) { + const { loading, error, data, fetchMore } = useQuery(NOTIFICATIONS, { + variables + }) if (error) return
Failed to load!
if (loading) { return } - const { notifications: { notifications, cursor } } = data return ( <> - {notifications.map(item => ( - + {/* XXX we shouldn't use the index but we don't have a unique id in this union yet */} + {notifications.map((n, i) => ( +
+ {n.__typename === 'Votification' && your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats} +
+ {n.item.title + ? + : } +
+
))} diff --git a/fragments/notifications.js b/fragments/notifications.js index 5e755867..fa26a739 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from '@apollo/client' import { ITEM_FIELDS } from './items' export const NOTIFICATIONS = gql` diff --git a/pages/_app.js b/pages/_app.js index cb7d4eb0..51b0dc52 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -49,6 +49,24 @@ const client = new ApolloClient({ } } } + }, + notifications: { + merge (existing, incoming, { readField }) { + const notifications = existing ? existing.notifications : [] + return { + cursor: incoming.cursor, + notifications: [...notifications, ...incoming.notifications] + } + }, + + read (existing) { + if (existing) { + return { + cursor: existing.cursor, + notifications: existing.notifications + } + } + } } } } diff --git a/pages/notifications.js b/pages/notifications.js index d0f6f4f1..c15810a9 100644 --- a/pages/notifications.js +++ b/pages/notifications.js @@ -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 ( -

- you stacked {data.recentlyStacked} sats -

- ) -} - -export default function Notifications ({ user }) { +export default function NotificationPage () { return ( - -
replies
- +
) } From 0afe46c030094c2907b779923aa9da26ceee124c Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 17 Aug 2021 18:59:22 -0500 Subject: [PATCH 19/34] continued notification work --- api/resolvers/notifications.js | 11 +++++++---- components/comment.js | 18 +++--------------- components/comment.module.css | 14 -------------- components/comments-flat.js | 17 +++++++++++++++-- components/header.js | 14 ++++++-------- components/notifications.js | 21 +++++++++++++++++++-- components/notifications.module.css | 9 +++++++++ pages/[username]/comments.js | 2 +- styles/globals.scss | 2 +- 9 files changed, 61 insertions(+), 47 deletions(-) create mode 100644 components/notifications.module.css diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index eac7a6bb..fc06bce3 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -5,9 +5,9 @@ export default { Query: { notifications: async (parent, { cursor }, { me, models }) => { const decodedCursor = decodeCursor(cursor) - // if (!me) { - // throw new AuthenticationError('you must be logged in') - // } + if (!me) { + throw new AuthenticationError('you must be logged in') + } /* So that we can cursor over results, we union notifications together ... @@ -63,12 +63,15 @@ export default { GROUP BY ${ITEM_SUBQUERY_FIELDS}, subquery.island ORDER BY max(subquery.voted_at) desc) ORDER BY sort_time DESC OFFSET $3 - LIMIT ${LIMIT}`, 622, decodedCursor.time, decodedCursor.offset) + LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset) notifications = notifications.map(n => { n.item = { ...n } return n }) + + await models.user.update({ where: { id: me.id }, data: { checkedNotesAt: new Date() } }) + return { cursor: notifications.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, notifications diff --git a/components/comment.js b/components/comment.js index 5620cb2b..829aed1f 100644 --- a/components/comment.js +++ b/components/comment.js @@ -38,7 +38,7 @@ function Parent ({ item, rootText }) { ) } -export default function Comment ({ item, children, replyOpen, includeParent, rootText, 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) @@ -50,28 +50,16 @@ export default function Comment ({ item, children, replyOpen, includeParent, roo const [canEdit, setCanEdit] = useState(mine && (Date.now() < editThreshold)) - console.log('wtf router', router, item.id, ref.current) - useEffect(() => { if (Number(router.query.commentId) === Number(item.id)) { - console.log(ref.current.scrollTop) ref.current.scrollIntoView() - // ref.current.classList.add('flash-it') + ref.current.classList.add('flash-it') } }, [item]) return (
{ - if (clickToContext) { - console.log('pushing') - // router.push(`/items/${item.parentId}?commentId=${item.id}`, `/items/${item.parentId}`, { scroll: false }) - router.push({ - pathname: '/items/[id]', - query: { id: 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 : ''}`} >
diff --git a/components/comment.module.css b/components/comment.module.css index e04fd681..1fc0216d 100644 --- a/components/comment.module.css +++ b/components/comment.module.css @@ -81,15 +81,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; @@ -99,9 +90,4 @@ padding-top: .25rem; border-top-left-radius: 0; border-top-right-radius: 0; -} - -.clickToContext { - scroll-behavior: smooth; - cursor: pointer; } \ No newline at end of file diff --git a/components/comments-flat.js b/components/comments-flat.js index f2cf7e7c..1c70b658 100644 --- a/components/comments-flat.js +++ b/components/comments-flat.js @@ -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 } - const { moreFlatComments: { comments, cursor } } = data return ( <> {comments.map(item => ( - +
{ + router.push({ + pathname: '/items/[id]', + query: { id: item.parentId, commentId: item.id } + }, `/items/${item.parentId}`) + }} + > + +
))} diff --git a/components/header.js b/components/header.js index f76426c8..4b59d2b7 100644 --- a/components/header.js +++ b/components/header.js @@ -14,7 +14,7 @@ import { useEffect } from 'react' import { randInRange } from '../lib/rand' function WalletSummary ({ me }) { - return `[${me.stacked},${me.sats}]` + return `${me.stacked} \\ ${me.sats}` } export default function Header () { @@ -32,20 +32,18 @@ export default function Header () { if (session) { return (
- {me && me.hasNewNotes && - - - } + + +
- + profile { // 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 diff --git a/components/notifications.js b/components/notifications.js index ebce1242..809e5c6d 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -4,8 +4,11 @@ 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' export default function Notifications ({ variables, ...props }) { + const router = useRouter() const { loading, error, data, fetchMore } = useQuery(NOTIFICATIONS, { variables }) @@ -14,12 +17,26 @@ export default function Notifications ({ variables, ...props }) { return } const { notifications: { notifications, cursor } } = data + return ( <> {/* XXX we shouldn't use the index but we don't have a unique id in this union yet */} {notifications.map((n, i) => ( -
- {n.__typename === 'Votification' && your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats} +
{ + if (n.__typename === 'Reply' || !n.item.title) { + router.push({ + pathname: '/items/[id]', + query: { id: n.item.parentId, commentId: n.item.id } + }, `/items/${n.item.parentId}`) + } else { + router.push(`items/${n.item.id}`) + } + }} + > + {n.__typename === 'Votification' && your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats}
{n.item.title ? diff --git a/components/notifications.module.css b/components/notifications.module.css new file mode 100644 index 00000000..c6ee5b81 --- /dev/null +++ b/components/notifications.module.css @@ -0,0 +1,9 @@ +.clickToContext { + border-radius: .4rem; + padding: .2rem 0; + cursor: pointer; +} + +.clickToContext:hover { + background-color: rgba(0, 0, 0, 0.03); +} \ No newline at end of file diff --git a/pages/[username]/comments.js b/pages/[username]/comments.js index c195b200..bb5fa892 100644 --- a/pages/[username]/comments.js +++ b/pages/[username]/comments.js @@ -39,7 +39,7 @@ export default function UserComments ({ user }) { - + ) } diff --git a/styles/globals.scss b/styles/globals.scss index fe80bd33..38ce80c8 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -191,7 +191,7 @@ footer { } .flash-it { - animation: flash 2s linear 2; + animation: flash 2s linear 1; } @keyframes spin { From c6e1cf74813de9bcb2fc937684d086d54ca4f42d Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 18 Aug 2021 08:52:45 -0500 Subject: [PATCH 20/34] improve sat/stacked display --- components/header.js | 2 +- components/layout.js | 2 +- components/user-header.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/header.js b/components/header.js index 4b59d2b7..a87e9d51 100644 --- a/components/header.js +++ b/components/header.js @@ -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 () { diff --git a/components/layout.js b/components/layout.js index ae4983b9..38852490 100644 --- a/components/layout.js +++ b/components/layout.js @@ -17,7 +17,7 @@ export default function Layout ({ noContain, noFooter, noSeo, children }) { {noContain ? children : ( - + {children} )} diff --git a/components/user-header.js b/components/user-header.js index 8f0578bf..226ce9ec 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -31,7 +31,7 @@ export default function UserHeader ({ user }) { const client = useApolloClient() const [setName] = useMutation(NAME_MUTATION) - const Satistics = () =>

[{user.stacked} stacked, {user.sats} sats]

+ const Satistics = () =>

{user.sats} sats \ {user.stacked} stacked

const UserSchema = Yup.object({ name: Yup.string() From b6aad73acae5cd71de25f7cab6c2fdb9f48e141d Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 18 Aug 2021 11:49:59 -0500 Subject: [PATCH 21/34] fix notifcation square positioning --- components/header.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/header.js b/components/header.js index a87e9d51..9f6278c0 100644 --- a/components/header.js +++ b/components/header.js @@ -35,7 +35,7 @@ export default function Header () { -
+
profile From 6dff4c6815e7502c01b3916313a12eb764c4d3c2 Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 18 Aug 2021 13:59:30 -0500 Subject: [PATCH 22/34] fix spacing in notifications --- components/comment.js | 2 +- components/comment.module.css | 1 + components/notifications.js | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/components/comment.js b/components/comment.js index 829aed1f..253ecc1b 100644 --- a/components/comment.js +++ b/components/comment.js @@ -110,7 +110,7 @@ export default function Comment ({ item, children, replyOpen, includeParent, roo )}
-
+
{!noReply && !edit && (
{n.__typename === 'Votification' && your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats} -
+
{n.item.title ? : } From 534e772849a9146aea9d33078cae142b09f427b4 Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 18 Aug 2021 14:15:52 -0500 Subject: [PATCH 23/34] fix notification padding and evict notification item from cache on visit --- components/notifications.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/notifications.js b/components/notifications.js index 4b602c47..286d7f10 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -1,4 +1,4 @@ -import { useQuery } from '@apollo/client' +import { useApolloClient, useQuery } from '@apollo/client' import Button from 'react-bootstrap/Button' import { useState } from 'react' import Comment, { CommentSkeleton } from './comment' @@ -9,6 +9,7 @@ import { useRouter } from 'next/router' export default function Notifications ({ variables, ...props }) { const router = useRouter() + const client = useApolloClient() const { loading, error, data, fetchMore } = useQuery(NOTIFICATIONS, { variables }) @@ -27,11 +28,15 @@ export default function Notifications ({ variables, ...props }) { 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}`) } }} From 3370675d61367f0c1aa57d2c46e33955e148612a Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 18 Aug 2021 17:20:33 -0500 Subject: [PATCH 24/34] db portion of mentions --- api/resolvers/item.js | 104 ++++++++++++------ .../20210818204542_mentions/migration.sql | 22 ++++ .../migration.sql | 8 ++ prisma/schema.prisma | 16 +++ 4 files changed, 115 insertions(+), 35 deletions(-) create mode 100644 prisma/migrations/20210818204542_mentions/migration.sql create mode 100644 prisma/migrations/20210818220200_mention_unique/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 6c8941cd..96d8aec2 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -61,30 +61,20 @@ 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 { - // notifications ... god such spagetti - 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 @@ -144,10 +134,7 @@ export default { throw new UserInputError('item can no longer be editted') } - return await models.item.update({ - where: { id: Number(id) }, - data: { title, url: ensureProtocol(url) } - }) + return await updateItem(parent, { id, data: { title, url: ensureProtocol(url) } }, { me, models }) }, createDiscussion: async (parent, { title, text }, { me, models }) => { if (!title) { @@ -175,10 +162,7 @@ export default { throw new UserInputError('item can no longer be editted') } - return await models.item.update({ - where: { id: Number(id) }, - data: { title, text } - }) + return await updateItem(parent, { id, data: { title, text } }, { me, models }) }, createComment: async (parent, { text, parentId }, { me, models }) => { if (!text) { @@ -210,10 +194,7 @@ export default { throw new UserInputError('comment can no longer be editted') } - return await models.item.update({ - where: { id: Number(id) }, - data: { text } - }) + 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 @@ -302,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') @@ -310,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 } diff --git a/prisma/migrations/20210818204542_mentions/migration.sql b/prisma/migrations/20210818204542_mentions/migration.sql new file mode 100644 index 00000000..8d3c1939 --- /dev/null +++ b/prisma/migrations/20210818204542_mentions/migration.sql @@ -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; diff --git a/prisma/migrations/20210818220200_mention_unique/migration.sql b/prisma/migrations/20210818220200_mention_unique/migration.sql new file mode 100644 index 00000000..93e69a72 --- /dev/null +++ b/prisma/migrations/20210818220200_mention_unique/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 287f7ff5..3de4471c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") From 4b649123332cd0f0b01f28b4a39ff2d636f5f77c Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 18 Aug 2021 18:00:54 -0500 Subject: [PATCH 25/34] mention notifications are functional --- api/resolvers/notifications.js | 18 ++++++++++++++---- api/resolvers/user.js | 15 ++++++++++++++- api/typeDefs/notifications.js | 7 ++++++- components/notifications.js | 12 ++++++++++-- fragments/notifications.js | 7 +++++++ 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index fc06bce3..98ed4003 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -43,13 +43,15 @@ export default { */ let notifications = await models.$queryRaw(` - SELECT ${ITEM_FIELDS}, "Item".created_at as sort_time, NULL as "earnedSats" + SELECT ${ITEM_FIELDS}, "Item".created_at as sort_time, 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 sort_time, sum(subquery.sats) as "earnedSats" + (SELECT ${ITEM_SUBQUERY_FIELDS}, max(subquery.voted_at) as sort_time, + 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) - @@ -57,10 +59,18 @@ export default { FROM "Vote" JOIN "Item" on "Vote"."itemId" = "Item".id WHERE "Vote"."userId" <> $1 - AND "Item".created_at <= $2 + 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 sort_time, NULL as "earnedSats", + true as mention + FROM "Mention" + JOIN "Item" on "Mention"."itemId" = "Item".id + WHERE "Mention"."userId" = $1 + AND "Mention".created_at <= $2 + AND "Item"."userId" <> $1) ORDER BY sort_time DESC OFFSET $3 LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset) @@ -80,7 +90,7 @@ export default { }, Notification: { __resolveType: async (notification, args, { models }) => - notification.earnedSats ? 'Votification' : 'Reply' + notification.earnedSats ? 'Votification' : (notification.mention ? 'Mention' : 'Reply') } } diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 92f84d13..4a7369a7 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -96,7 +96,20 @@ export default { 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 } } } diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 1374c5c2..47c4f369 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -14,7 +14,12 @@ export default gql` item: Item! } - union Notification = Reply | Votification + type Mention { + mention: Boolean! + item: Item! + } + + union Notification = Reply | Votification | Mention type Notifications { cursor: String diff --git a/components/notifications.js b/components/notifications.js index 286d7f10..81f77eac 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -41,8 +41,16 @@ export default function Notifications ({ variables, ...props }) { } }} > - {n.__typename === 'Votification' && your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats} -
+ {n.__typename === 'Votification' && + your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats} + {n.__typename === 'Mention' && + you were mentioned in} +
{n.item.title ? : } diff --git a/fragments/notifications.js b/fragments/notifications.js index fa26a739..0cb531a2 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -9,6 +9,13 @@ export const NOTIFICATIONS = gql` cursor notifications { __typename + ... on Mention { + mention + item { + ...ItemFields + text + } + } ... on Votification { earnedSats item { From d97608a7104e9f7240e57e021b9e2b24eb08fd8e Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 18 Aug 2021 18:14:18 -0500 Subject: [PATCH 26/34] don't double notify when mentioned in reply to your own content --- api/resolvers/notifications.js | 4 +++- components/notifications.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 98ed4003..9d7f2d42 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -68,9 +68,11 @@ export default { 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 "Item"."userId" <> $1 + AND p."userId" <> $1) ORDER BY sort_time DESC OFFSET $3 LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset) diff --git a/components/notifications.js b/components/notifications.js index 81f77eac..8bf60f15 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -47,7 +47,7 @@ export default function Notifications ({ variables, ...props }) { you were mentioned in}
From c239d05e30423f2f5a3f8281a38e31bcf0e7f7c0 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 19 Aug 2021 15:12:36 -0500 Subject: [PATCH 27/34] add --trace-warnings --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 4c233de1..80f255b5 100644 --- a/package.json +++ b/package.json @@ -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", @@ -63,4 +63,4 @@ "eslint-plugin-compat": "^3.9.0", "standard": "^16.0.3" } -} +} \ No newline at end of file From 099a578a1be9ce9943207f4ba16bd25cae25e2e6 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 19 Aug 2021 14:53:11 -0500 Subject: [PATCH 28/34] protect from null mentions --- api/resolvers/item.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 96d8aec2..20b5cac5 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -294,8 +294,8 @@ const createMentions = async (item, models) => { } try { - const mentions = item.text.match(namePattern).map(m => m.slice(1)) - if (mentions.length > 0) { + 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 } From 81d5647cd92c8de64032596ebde81e3f563db9c1 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 19 Aug 2021 14:53:38 -0500 Subject: [PATCH 29/34] remove withdrawal event listeners --- api/resolvers/wallet.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 2ab95caf..fbc9afe6 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -123,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 @@ -135,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' From 06c75eb78e205e9afb7bf310685b62b4e01b3614 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 19 Aug 2021 15:34:33 -0500 Subject: [PATCH 30/34] try to fix 'stacked' wrapping --- components/header.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/header.js b/components/header.js index 9f6278c0..ef2d1565 100644 --- a/components/header.js +++ b/components/header.js @@ -81,7 +81,7 @@ export default function Header () { {me && - + }
From 4a770b61b66b19a77ebc8df26755f78d36a08ddf Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 19 Aug 2021 16:13:33 -0500 Subject: [PATCH 31/34] cmd or ctrl enter to submit --- components/form.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/form.js b/components/form.js index 7879d125..6c78ebe8 100644 --- a/components/form.js +++ b/components/form.js @@ -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 ( <> @@ -120,6 +120,11 @@ function InputInner ({ prepend, append, hint, showValid, ...props }) { )} { + if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) { + formik?.submitForm() + } + }} {...field} {...props} isInvalid={meta.touched && meta.error} isValid={showValid && meta.touched && !meta.error} From 2703f1b9875078b43e1cfc2d1dc0dfe65da632a0 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 19 Aug 2021 16:19:35 -0500 Subject: [PATCH 32/34] rss link to url if available --- lib/rss.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/rss.js b/lib/rss.js index ae1e3b26..6cd643eb 100644 --- a/lib/rss.js +++ b/lib/rss.js @@ -15,11 +15,15 @@ function escapeXml (unsafe) { } const generateRssItem = (item) => { + const guid = `${SITE_URL}/items/${item.id}` + const link = item.url || guid return ` ${SITE_URL}/items/${item.id} ${escapeXml(item.title)} - ${SITE_URL}/items/${item.id} + ${link} + ${guid} + Comments]]> ${new Date(item.createdAt).toUTCString()} ` From 79cb2d5c27e99db5f796c63ac432a2ff1a61c022 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 19 Aug 2021 16:42:21 -0500 Subject: [PATCH 33/34] withdrawl => withdrawal/withdraw --- README.md | 2 +- api/resolvers/serial.js | 4 ++-- api/resolvers/wallet.js | 2 +- pages/wallet.js | 10 +++++----- pages/{withdrawls => withdrawals}/[id].js | 0 .../20210819213404_user_withdrawal/migration.sql | 2 ++ .../20210819213613_user_withdrawals/migration.sql | 2 ++ .../20210819213954_user_withdraw/migration.sql | 3 +++ 8 files changed, 16 insertions(+), 9 deletions(-) rename pages/{withdrawls => withdrawals}/[id].js (100%) create mode 100644 prisma/migrations/20210819213404_user_withdrawal/migration.sql create mode 100644 prisma/migrations/20210819213613_user_withdrawals/migration.sql create mode 100644 prisma/migrations/20210819213954_user_withdraw/migration.sql diff --git a/README.md b/README.md index 7877f95f..7e71e476 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/api/resolvers/serial.js b/api/resolvers/serial.js index 115fca9b..da841435 100644 --- a/api/resolvers/serial.js +++ b/api/resolvers/serial.js @@ -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') diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index fbc9afe6..cf3e3d2f 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -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 diff --git a/pages/wallet.js b/pages/wallet.js index e5daa919..17048ea6 100644 --- a/pages/wallet.js +++ b/pages/wallet.js @@ -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,8 +38,8 @@ export function WalletForm () { or - - + +
) @@ -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}`) }} > sats} /> - withdrawl + withdraw ) diff --git a/pages/withdrawls/[id].js b/pages/withdrawals/[id].js similarity index 100% rename from pages/withdrawls/[id].js rename to pages/withdrawals/[id].js diff --git a/prisma/migrations/20210819213404_user_withdrawal/migration.sql b/prisma/migrations/20210819213404_user_withdrawal/migration.sql new file mode 100644 index 00000000..41819cdb --- /dev/null +++ b/prisma/migrations/20210819213404_user_withdrawal/migration.sql @@ -0,0 +1,2 @@ +-- add withdrawal as user +INSERT INTO "users" ("name") VALUES ('withdrawal'); \ No newline at end of file diff --git a/prisma/migrations/20210819213613_user_withdrawals/migration.sql b/prisma/migrations/20210819213613_user_withdrawals/migration.sql new file mode 100644 index 00000000..3c91e7cb --- /dev/null +++ b/prisma/migrations/20210819213613_user_withdrawals/migration.sql @@ -0,0 +1,2 @@ +-- add withdrawals as user +INSERT INTO "users" ("name") VALUES ('withdrawals'); \ No newline at end of file diff --git a/prisma/migrations/20210819213954_user_withdraw/migration.sql b/prisma/migrations/20210819213954_user_withdraw/migration.sql new file mode 100644 index 00000000..1dcb85fa --- /dev/null +++ b/prisma/migrations/20210819213954_user_withdraw/migration.sql @@ -0,0 +1,3 @@ +-- withdraw users +INSERT INTO "users" ("name") VALUES ('withdraw'); +INSERT INTO "users" ("name") VALUES ('withdraws'); \ No newline at end of file From 09b358397a04c3fcbdfe5603d727eda00ccfbd90 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 19 Aug 2021 19:13:32 -0500 Subject: [PATCH 34/34] highlight new notifications --- api/resolvers/notifications.js | 10 ++-- api/typeDefs/notifications.js | 4 ++ components/notifications.js | 91 +++++++++++++++++------------ components/notifications.module.css | 5 ++ fragments/notifications.js | 4 ++ pages/_app.js | 10 +--- 6 files changed, 77 insertions(+), 47 deletions(-) diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 9d7f2d42..59b752fb 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -43,14 +43,14 @@ export default { */ let notifications = await models.$queryRaw(` - SELECT ${ITEM_FIELDS}, "Item".created_at as sort_time, NULL as "earnedSats", + 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 sort_time, + (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, @@ -64,7 +64,7 @@ export default { 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 sort_time, NULL as "earnedSats", + (SELECT ${ITEM_FIELDS}, "Mention".created_at as "sortTime", NULL as "earnedSats", true as mention FROM "Mention" JOIN "Item" on "Mention"."itemId" = "Item".id @@ -73,7 +73,7 @@ export default { AND "Mention".created_at <= $2 AND "Item"."userId" <> $1 AND p."userId" <> $1) - ORDER BY sort_time DESC + ORDER BY "sortTime" DESC OFFSET $3 LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset) @@ -82,9 +82,11 @@ export default { 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 } diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 47c4f369..3071db4e 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -8,20 +8,24 @@ export default gql` 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!]! } diff --git a/components/notifications.js b/components/notifications.js index 8bf60f15..0632d519 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -7,9 +7,47 @@ import { NOTIFICATIONS } from '../fragments/notifications' import styles from './notifications.module.css' import { useRouter } from 'next/router' -export default function Notifications ({ variables, ...props }) { +function Notification ({ key, n }) { const router = useRouter() const client = useApolloClient() + return ( +
{ + 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' && + your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats} + {n.__typename === 'Mention' && + you were mentioned in} +
+ {n.item.title + ? + : } +
+
+ ) +} + +export default function Notifications ({ variables }) { const { loading, error, data, fetchMore } = useQuery(NOTIFICATIONS, { variables }) @@ -17,45 +55,26 @@ export default function Notifications ({ variables, ...props }) { if (loading) { return } - const { notifications: { notifications, cursor } } = data + + 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 */} - {notifications.map((n, i) => ( -
{ - 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' && - your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats} - {n.__typename === 'Mention' && - you were mentioned in} -
- {n.item.title - ? - : } -
-
+
+ {fresh.map((n, i) => ( + + ))} +
+ {old.map((n, i) => ( + ))} diff --git a/components/notifications.module.css b/components/notifications.module.css index c6ee5b81..343e1ca0 100644 --- a/components/notifications.module.css +++ b/components/notifications.module.css @@ -6,4 +6,9 @@ .clickToContext:hover { background-color: rgba(0, 0, 0, 0.03); +} + +.fresh { + background-color: rgba(0, 0, 0, 0.03); + border-radius: .4rem; } \ No newline at end of file diff --git a/fragments/notifications.js b/fragments/notifications.js index 0cb531a2..f855e30f 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -7,9 +7,11 @@ export const NOTIFICATIONS = gql` query Notifications($cursor: String) { notifications(cursor: $cursor) { cursor + lastChecked notifications { __typename ... on Mention { + sortTime mention item { ...ItemFields @@ -17,6 +19,7 @@ export const NOTIFICATIONS = gql` } } ... on Votification { + sortTime earnedSats item { ...ItemFields @@ -24,6 +27,7 @@ export const NOTIFICATIONS = gql` } } ... on Reply { + sortTime item { ...ItemFields text diff --git a/pages/_app.js b/pages/_app.js index 51b0dc52..95895291 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -55,17 +55,13 @@ const client = new ApolloClient({ const notifications = existing ? existing.notifications : [] return { cursor: incoming.cursor, - notifications: [...notifications, ...incoming.notifications] + notifications: [...notifications, ...incoming.notifications], + lastChecked: incoming.lastChecked } }, read (existing) { - if (existing) { - return { - cursor: existing.cursor, - notifications: existing.notifications - } - } + return existing } } }