From 18910fa2ed470fe12ffbfb83df18e206101f0a05 Mon Sep 17 00:00:00 2001 From: keyan Date: Sun, 23 Jul 2023 09:16:12 -0500 Subject: [PATCH] Revert "shield your eyes; massive, squashed refactor; nextjs/react/react-dom/apollo upgrades" This reverts commit d0314ab73c42ebf85fe2f418dddd681af45dbc55. --- .npmrc | 1 + api/resolvers/invite.js | 8 +- api/resolvers/item.js | 519 +- api/resolvers/lnurl.js | 4 +- api/resolvers/message.js | 4 +- api/resolvers/notifications.js | 12 +- api/resolvers/referrals.js | 4 +- api/resolvers/rewards.js | 4 +- api/resolvers/search.js | 11 +- api/resolvers/serial.js | 4 +- api/resolvers/sub.js | 2 - api/resolvers/upload.js | 10 +- api/resolvers/user.js | 74 +- api/resolvers/wallet.js | 22 +- api/ssrApollo.js | 46 +- api/typeDefs/admin.js | 2 +- api/typeDefs/growth.js | 2 +- api/typeDefs/index.js | 2 +- api/typeDefs/invite.js | 2 +- api/typeDefs/item.js | 15 +- api/typeDefs/lnurl.js | 2 +- api/typeDefs/message.js | 2 +- api/typeDefs/notifications.js | 13 +- api/typeDefs/price.js | 2 +- api/typeDefs/referrals.js | 2 +- api/typeDefs/rewards.js | 2 +- api/typeDefs/sub.js | 4 +- api/typeDefs/upload.js | 2 +- api/typeDefs/user.js | 7 +- api/typeDefs/wallet.js | 2 +- components/avatar.js | 118 +- components/bookmark.js | 2 +- components/bounty-form.js | 2 + components/comment-edit.js | 2 + components/comment.js | 110 +- components/comments-flat.js | 42 + components/comments.js | 23 +- components/countdown.js | 2 +- components/dark-mode.js | 17 - components/delete.js | 2 +- components/discussion-form.js | 18 +- components/error-boundary.js | 8 +- components/fee-button.js | 2 +- components/footer-rewards.js | 8 +- components/footer.js | 149 +- components/form.js | 103 +- components/header.js | 496 +- components/info.js | 30 +- components/item-full.js | 55 +- components/item-info.js | 44 +- components/item-job.js | 38 +- components/item.js | 26 +- components/item.module.css | 4 +- components/items-mixed.js | 34 + components/items.js | 64 +- components/job-form.js | 6 +- components/layout-center.js | 14 + ...ut.module.css => layout-center.module.css} | 0 components/layout-static.js | 15 + components/layout.js | 67 +- components/lightning-auth.js | 2 +- components/lightning.js | 68 +- components/link-form.js | 33 +- components/login-button.js | 9 +- components/me.js | 2 +- components/modal-button.js | 21 + components/more-footer.js | 20 +- components/notifications.js | 153 +- components/page-loading.js | 9 - components/past-bounties.js | 46 +- components/pay-bounty.js | 36 +- components/poll-form.js | 2 + components/price.js | 6 +- components/recent-header.js | 8 +- components/related.js | 38 +- components/reply.js | 75 +- components/search-items.js | 51 + components/search.js | 8 +- components/seo.js | 2 +- components/snl.js | 11 +- components/subscribe.js | 2 +- components/table-of-contents.js | 2 +- components/text.js | 52 +- components/text.module.css | 4 +- components/top-header.js | 22 +- components/upvote.js | 26 +- components/usage-header.js | 3 +- components/user-header.js | 285 +- components/user-header.module.css | 15 +- components/user-list.js | 65 +- fragments/comments.js | 64 +- fragments/items.js | 91 + fragments/notifications.js | 10 - fragments/subs.js | 104 +- fragments/users.js | 89 +- lexical/nodes/image.js | 461 + lexical/plugins/autolink.js | 34 + lexical/plugins/image-insert.js | 248 + lexical/plugins/link-insert.js | 129 + lexical/plugins/link-tooltip.js | 232 + lexical/plugins/list-max-indent.js | 68 + lexical/plugins/toolbar.js | 383 + lexical/styles.module.css | 256 + lexical/theme.js | 37 + lexical/utils/image-markdown-transformer.js | 55 + lexical/utils/link-from-selection.js | 24 + lexical/utils/modal.js | 34 + lexical/utils/selected-node.js | 17 + lexical/utils/tooltip-position.js | 41 + lexical/utils/url.js | 24 + lib/apollo.js | 124 +- lib/clicks.js | 8 +- lib/constants.js | 20 - lib/item.js | 11 - lib/rss.js | 2 +- lib/time.js | 4 - lib/validate.js | 2 +- next.config.js | 16 +- package-lock.json | 7926 ++++++++--------- package.json | 29 +- pages/404.js | 8 +- pages/500.js | 10 +- pages/[name]/[type].js | 93 - pages/[name]/bookmarks.js | 33 + pages/[name]/bounties.js | 23 + pages/[name]/comments.js | 30 + pages/[name]/index.js | 31 +- pages/[name]/posts.js | 31 + pages/_app.js | 68 +- pages/_document.js | 14 +- pages/api/graphql.js | 27 +- pages/borderland.js | 32 + pages/email.js | 8 +- pages/freebie.js | 32 + pages/index.js | 19 + pages/invites/[id].js | 8 +- pages/invoices/[id].js | 33 +- pages/items/[id]/edit.js | 19 +- pages/items/[id]/index.js | 19 +- pages/items/[id]/ots.js | 13 +- pages/items/[id]/related.js | 11 +- pages/lexical.js | 146 + pages/live.js | 12 +- pages/login.js | 6 +- pages/notifications.js | 10 +- pages/offline.js | 8 +- pages/outlawed.js | 32 + pages/post.js | 10 + pages/recent/[type].js | 22 + pages/recent/comments.js | 20 + pages/recent/index.js | 20 + pages/referrals/[when].js | 52 +- pages/rewards.js | 100 +- pages/rss.js | 9 + pages/satistics.js | 177 +- pages/search.js | 28 + pages/settings.js | 214 +- pages/signup.js | 6 +- pages/stackers/[when].js | 2 +- pages/stackers/search.js | 22 +- pages/top/comments/[when].js | 25 + pages/top/cowboys.js | 25 + pages/top/posts/[when].js | 24 + pages/top/stackers/[when].js | 30 + pages/wallet.js | 12 +- pages/withdrawals/[id].js | 13 +- pages/~/[sub]/index.js | 18 + pages/~/[sub]/post.js | 15 + pages/~/[sub]/recent/[type].js | 22 + pages/~/[sub]/recent/comments.js | 20 + pages/~/[sub]/recent/index.js | 22 + pages/~/[sub]/rss.js | 9 + pages/~/[sub]/search.js | 22 + pages/~/[sub]/top/comments/[when].js | 25 + pages/~/[sub]/top/posts/[when].js | 25 + pages/~/index.js | 19 - pages/~/post.js | 24 - pages/~/recent/[type].js | 31 - pages/~/recent/index.js | 1 - pages/~/rss.js | 9 - pages/~/search.js | 32 - pages/~/top/[type]/[when].js | 32 - pages/~/top/cowboys.js | 19 - pages/~/top/stackers/[when].js | 23 - public/Lightningvolt-xoqm.woff | Bin 8572 -> 0 bytes public/Lightningvolt-xoqm.woff2 | Bin 6900 -> 0 bytes public/{dark.js => darkmode.js} | 73 +- styles/globals.scss | 12 +- styles/satistics.module.css | 55 +- svgs/file-warning-line.svg | 1 - worker/ots.js | 2 +- worker/search.js | 12 +- 192 files changed, 9122 insertions(+), 6894 deletions(-) create mode 100644 components/comments-flat.js delete mode 100644 components/dark-mode.js create mode 100644 components/items-mixed.js create mode 100644 components/layout-center.js rename components/{layout.module.css => layout-center.module.css} (100%) create mode 100644 components/layout-static.js create mode 100644 components/modal-button.js delete mode 100644 components/page-loading.js create mode 100644 components/search-items.js create mode 100644 lexical/nodes/image.js create mode 100644 lexical/plugins/autolink.js create mode 100644 lexical/plugins/image-insert.js create mode 100644 lexical/plugins/link-insert.js create mode 100644 lexical/plugins/link-tooltip.js create mode 100644 lexical/plugins/list-max-indent.js create mode 100644 lexical/plugins/toolbar.js create mode 100644 lexical/styles.module.css create mode 100644 lexical/theme.js create mode 100644 lexical/utils/image-markdown-transformer.js create mode 100644 lexical/utils/link-from-selection.js create mode 100644 lexical/utils/modal.js create mode 100644 lexical/utils/selected-node.js create mode 100644 lexical/utils/tooltip-position.js create mode 100644 lexical/utils/url.js delete mode 100644 lib/item.js delete mode 100644 pages/[name]/[type].js create mode 100644 pages/[name]/bookmarks.js create mode 100644 pages/[name]/bounties.js create mode 100644 pages/[name]/comments.js create mode 100644 pages/[name]/posts.js create mode 100644 pages/borderland.js create mode 100644 pages/freebie.js create mode 100644 pages/index.js create mode 100644 pages/lexical.js create mode 100644 pages/outlawed.js create mode 100644 pages/post.js create mode 100644 pages/recent/[type].js create mode 100644 pages/recent/comments.js create mode 100644 pages/recent/index.js create mode 100644 pages/rss.js create mode 100644 pages/search.js create mode 100644 pages/top/comments/[when].js create mode 100644 pages/top/cowboys.js create mode 100644 pages/top/posts/[when].js create mode 100644 pages/top/stackers/[when].js create mode 100644 pages/~/[sub]/index.js create mode 100644 pages/~/[sub]/post.js create mode 100644 pages/~/[sub]/recent/[type].js create mode 100644 pages/~/[sub]/recent/comments.js create mode 100644 pages/~/[sub]/recent/index.js create mode 100644 pages/~/[sub]/rss.js create mode 100644 pages/~/[sub]/search.js create mode 100644 pages/~/[sub]/top/comments/[when].js create mode 100644 pages/~/[sub]/top/posts/[when].js delete mode 100644 pages/~/index.js delete mode 100644 pages/~/post.js delete mode 100644 pages/~/recent/[type].js delete mode 100644 pages/~/recent/index.js delete mode 100644 pages/~/rss.js delete mode 100644 pages/~/search.js delete mode 100644 pages/~/top/[type]/[when].js delete mode 100644 pages/~/top/cowboys.js delete mode 100644 pages/~/top/stackers/[when].js delete mode 100644 public/Lightningvolt-xoqm.woff delete mode 100644 public/Lightningvolt-xoqm.woff2 rename public/{dark.js => darkmode.js} (61%) delete mode 100644 svgs/file-warning-line.svg diff --git a/.npmrc b/.npmrc index 93b53126..b77945ac 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ unsafe-perm=true + legacy-peer-deps=true \ No newline at end of file diff --git a/api/resolvers/invite.js b/api/resolvers/invite.js index 6f9bac8d..8b176d7b 100644 --- a/api/resolvers/invite.js +++ b/api/resolvers/invite.js @@ -1,11 +1,11 @@ -import { GraphQLError } from 'graphql' +import { AuthenticationError } from 'apollo-server-micro' import { inviteSchema, ssValidate } from '../../lib/validate' export default { Query: { invites: async (parent, args, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in') } return await models.invite.findMany({ @@ -29,7 +29,7 @@ export default { Mutation: { createInvite: async (parent, { gift, limit }, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in') } await ssValidate(inviteSchema, { gift, limit }) @@ -40,7 +40,7 @@ export default { }, revokeInvite: async (parent, { id }, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in') } return await models.invite.update({ diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 43dff025..4967a4d0 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -1,4 +1,4 @@ -import { GraphQLError } from 'graphql' +import { UserInputError, AuthenticationError } from 'apollo-server-micro' import { ensureProtocol, removeTracking } from '../../lib/url' import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' @@ -6,8 +6,7 @@ import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import domino from 'domino' import { BOOST_MIN, ITEM_SPAM_INTERVAL, - MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, - DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY + MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT } from '../../lib/constants' import { msatsToSats } from '../../lib/format' import { parse } from 'tldts' @@ -15,26 +14,6 @@ import uu from 'url-unshort' import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate' import { sendUserNotification } from '../webPush' import { proxyImages } from './imgproxy' -import { defaultCommentSort } from '../../lib/item' - -export async function commentFilterClause (me, models) { - let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` - if (me) { - const user = await models.user.findUnique({ where: { id: me.id } }) - // wild west mode has everything - if (user.wildWestMode) { - return '' - } - - // always include if it's mine - clause += ` OR "Item"."userId" = ${me.id}` - } - - // close the clause - clause += ')' - - return clause -} async function comments (me, models, id, sort) { let orderBy @@ -74,9 +53,9 @@ export async function getItem (parent, { id }, { me, models }) { return item } -function whenClause (when, type) { - let interval = ` AND "${type === 'bookmarks' ? 'Bookmark' : 'Item'}".created_at >= $1 - INTERVAL ` - switch (when) { +function topClause (within) { + let interval = ' AND "Item".created_at >= $1 - INTERVAL ' + switch (within) { case 'forever': interval = '' break @@ -96,16 +75,14 @@ function whenClause (when, type) { return interval } -const orderByClause = async (by, me, models, type) => { - switch (by) { +async function topOrderClause (sort, me, models) { + switch (sort) { case 'comments': - return 'ORDER BY "Item".ncomments DESC' + return 'ORDER BY ncomments DESC' case 'sats': - return 'ORDER BY "Item".msats DESC' - case 'votes': - return await topOrderByWeightedSats(me, models) + return 'ORDER BY msats DESC' default: - return `ORDER BY "${type === 'bookmarks' ? 'Bookmark' : 'Item'}".created_at DESC` + return await topOrderByWeightedSats(me, models) } } @@ -135,17 +112,26 @@ export async function joinSatRankView (me, models) { return 'JOIN sat_rank_tender_view ON "Item".id = sat_rank_tender_view.id' } -export async function filterClause (me, models, type) { - // if you are explicitly asking for marginal content, don't filter them - if (['outlawed', 'borderland', 'freebies'].includes(type)) { - if (me && ['outlawed', 'borderland'].includes(type)) { - // unless the item is mine - return ` AND "Item"."userId" <> ${me.id} ` +export async function commentFilterClause (me, models) { + let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` + if (me) { + const user = await models.user.findUnique({ where: { id: me.id } }) + // wild west mode has everything + if (user.wildWestMode) { + return '' } - return '' + // always include if it's mine + clause += ` OR "Item"."userId" = ${me.id}` } + // close the clause + clause += ')' + + return clause +} + +export async function filterClause (me, models) { // by default don't include freebies unless they have upvotes let clause = ' AND (NOT "Item".freebie OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0' if (me) { @@ -176,33 +162,20 @@ export async function filterClause (me, models, type) { return clause } -function typeClause (type) { +function recentClause (type) { switch (type) { case 'links': - return ' AND "Item".url IS NOT NULL AND "Item"."parentId" IS NULL' + return ' AND url IS NOT NULL' case 'discussions': - return ' AND "Item".url IS NULL AND "Item".bio = false AND "Item"."pollCost" IS NULL AND "Item"."parentId" IS NULL' + return ' AND url IS NULL AND bio = false AND "pollCost" IS NULL' case 'polls': - return ' AND "Item"."pollCost" IS NOT NULL AND "Item"."parentId" IS NULL' + return ' AND "pollCost" IS NOT NULL' case 'bios': - return ' AND "Item".bio = true AND "Item"."parentId" IS NULL' + return ' AND bio = true' case 'bounties': - return ' AND "Item".bounty IS NOT NULL AND "Item"."parentId" IS NULL' - case 'comments': - return ' AND "Item"."parentId" IS NOT NULL' - case 'freebies': - return ' AND "Item".freebie' - case 'outlawed': - return ` AND "Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD}` - case 'borderland': - return ' AND "Item"."weightedVotes" - "Item"."weightedDownVotes" < 0 ' - case 'all': - case 'bookmarks': - return '' - case 'jobs': - return ' AND "Item"."subName" = \'jobs\'' + return ' AND bounty IS NOT NULL' default: - return ' AND "Item"."parentId" IS NULL' + return '' } } @@ -245,28 +218,6 @@ const subClause = (sub, num, table, solo) => { return sub ? ` ${solo ? 'WHERE' : 'AND'} ${table ? `"${table}".` : ''}"subName" = $${num} ` : '' } -const relationClause = (type) => { - switch (type) { - case 'comments': - return ' FROM "Item" JOIN "Item" root ON "Item"."rootId" = root.id ' - case 'bookmarks': - return ' FROM "Item" JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" ' - case 'outlawed': - case 'borderland': - case 'freebies': - case 'all': - return ' FROM "Item" LEFT JOIN "Item" root ON "Item"."rootId" = root.id ' - default: - return ' FROM "Item" ' - } -} - -const subClauseTable = (type) => COMMENT_TYPE_QUERY.includes(type) ? 'root' : 'Item' - -const activeOrMine = (me) => { - return me ? ` AND ("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id}) ` : ' AND "Item".status <> \'STOPPED\' ' -} - export default { Query: { itemRepetition: async (parent, { parentId }, { me, models }) => { @@ -277,9 +228,62 @@ export default { return count }, - items: async (parent, { sub, sort, type, cursor, name, when, by, limit = LIMIT }, { me, models }) => { + topItems: async (parent, { sub, cursor, sort, when }, { me, models }) => { const decodedCursor = decodeCursor(cursor) - let items, user, pins, subFull, table + const subArr = sub ? [sub] : [] + const items = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT} + FROM "Item" + WHERE "parentId" IS NULL AND "Item".created_at <= $1 + AND "pinId" IS NULL AND "deletedAt" IS NULL + ${subClause(sub, 3)} + ${topClause(when)} + ${await filterClause(me, models)} + ${await topOrderClause(sort, me, models)} + OFFSET $2 + LIMIT ${LIMIT}`, + orderBy: await topOrderClause(sort, me, models) + }, decodedCursor.time, decodedCursor.offset, ...subArr) + return { + cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + items + } + }, + topComments: async (parent, { sub, cursor, sort, when }, { me, models }) => { + const decodedCursor = decodeCursor(cursor) + const subArr = sub ? [sub] : [] + const comments = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT} + FROM "Item" + JOIN "Item" root ON "Item"."rootId" = root.id + WHERE "Item"."parentId" IS NOT NULL + AND "Item".created_at <= $1 AND "Item"."deletedAt" IS NULL + ${subClause(sub, 3, 'root')} + ${topClause(when)} + ${await filterClause(me, models)} + ${await topOrderClause(sort, me, models)} + OFFSET $2 + LIMIT ${LIMIT}`, + orderBy: await topOrderClause(sort, me, models) + }, decodedCursor.time, decodedCursor.offset, ...subArr) + return { + cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + comments + } + }, + items: async (parent, { sub, sort, type, cursor, name, within }, { me, models }) => { + const decodedCursor = decodeCursor(cursor) + let items; let user; let pins; let subFull + + const activeOrMine = () => { + return me ? ` AND (status <> 'STOPPED' OR "userId" = ${me.id}) ` : ' AND status <> \'STOPPED\' ' + } // HACK we want to optionally include the subName in the query // but the query planner doesn't like unused parameters @@ -288,32 +292,29 @@ export default { switch (sort) { case 'user': if (!name) { - throw new GraphQLError('must supply name', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('must supply name', { argumentName: 'name' }) } user = await models.user.findUnique({ where: { name } }) if (!user) { - throw new GraphQLError('no user has that name', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('no user has that name', { argumentName: 'name' }) } - table = type === 'bookmarks' ? 'Bookmark' : 'Item' items = await itemQueryWithMeta({ me, models, query: ` ${SELECT} - ${relationClause(type)} - WHERE "${table}"."userId" = $2 AND "${table}".created_at <= $1 - ${subClause(sub, 5, subClauseTable(type))} - ${activeOrMine(me)} - ${await filterClause(me, models, type)} - ${typeClause(type)} - ${whenClause(when || 'forever', type)} - ${await orderByClause(by, me, models, type)} + FROM "Item" + WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2 + AND "pinId" IS NULL + ${activeOrMine()} + ${await filterClause(me, models)} + ORDER BY created_at DESC OFFSET $3 - LIMIT $4`, - orderBy: await orderByClause(by, me, models, type) - }, decodedCursor.time, user.id, decodedCursor.offset, limit, ...subArr) + LIMIT ${LIMIT}`, + orderBy: 'ORDER BY "Item"."createdAt" DESC' + }, user.id, decodedCursor.time, decodedCursor.offset) break case 'recent': items = await itemQueryWithMeta({ @@ -321,17 +322,17 @@ export default { models, query: ` ${SELECT} - ${relationClause(type)} - WHERE "Item".created_at <= $1 - ${subClause(sub, 4, subClauseTable(type))} - ${activeOrMine(me)} - ${await filterClause(me, models, type)} - ${typeClause(type)} - ORDER BY "Item".created_at DESC + FROM "Item" + WHERE "parentId" IS NULL AND created_at <= $1 + ${subClause(sub, 3)} + ${activeOrMine()} + ${await filterClause(me, models)} + ${recentClause(type)} + ORDER BY created_at DESC OFFSET $2 - LIMIT $3`, + LIMIT ${LIMIT}`, orderBy: 'ORDER BY "Item"."createdAt" DESC' - }, decodedCursor.time, decodedCursor.offset, limit, ...subArr) + }, decodedCursor.time, decodedCursor.offset, ...subArr) break case 'top': items = await itemQueryWithMeta({ @@ -339,18 +340,16 @@ export default { models, query: ` ${SELECT} - ${relationClause(type)} - WHERE "Item".created_at <= $1 - AND "Item"."pinId" IS NULL AND "Item"."deletedAt" IS NULL - ${subClause(sub, 4, subClauseTable(type))} - ${typeClause(type)} - ${whenClause(when, type)} - ${await filterClause(me, models, type)} - ${await orderByClause(by || 'votes', me, models, type)} + FROM "Item" + WHERE "parentId" IS NULL AND "Item".created_at <= $1 + AND "pinId" IS NULL AND "deletedAt" IS NULL + ${topClause(within)} + ${await filterClause(me, models)} + ${await topOrderByWeightedSats(me, models)} OFFSET $2 - LIMIT $3`, - orderBy: await orderByClause(by || 'votes', me, models, type) - }, decodedCursor.time, decodedCursor.offset, limit, ...subArr) + LIMIT ${LIMIT}`, + orderBy: await topOrderByWeightedSats(me, models) + }, decodedCursor.time, decodedCursor.offset) break default: // sub so we know the default ranking @@ -373,13 +372,13 @@ export default { FROM "Item" WHERE "parentId" IS NULL AND created_at <= $1 AND "pinId" IS NULL - ${subClause(sub, 4)} + ${subClause(sub, 3)} AND status IN ('ACTIVE', 'NOSATS') ORDER BY group_rank, rank OFFSET $2 - LIMIT $3`, + LIMIT ${LIMIT}`, orderBy: 'ORDER BY group_rank, rank' - }, decodedCursor.time, decodedCursor.offset, limit, ...subArr) + }, decodedCursor.time, decodedCursor.offset, ...subArr) break default: items = await itemQueryWithMeta({ @@ -389,12 +388,12 @@ export default { ${SELECT}, rank FROM "Item" ${await joinSatRankView(me, models)} - ${subClause(sub, 3, 'Item', true)} + ${subClause(sub, 2, 'Item', true)} ORDER BY rank ASC OFFSET $1 - LIMIT $2`, + LIMIT ${LIMIT}`, orderBy: 'ORDER BY rank ASC' - }, decodedCursor.offset, limit, ...subArr) + }, decodedCursor.offset, ...subArr) if (decodedCursor.offset === 0) { // get pins for the page and return those separately @@ -420,11 +419,230 @@ export default { break } return { - cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null, + cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, items, pins } }, + allItems: async (parent, { cursor }, { me, models }) => { + const decodedCursor = decodeCursor(cursor) + const items = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT} + FROM "Item" + ORDER BY created_at DESC + OFFSET $1 + LIMIT ${LIMIT}`, + orderBy: 'ORDER BY "Item"."createdAt" DESC' + }, decodedCursor.offset) + return { + cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + items + } + }, + outlawedItems: async (parent, { cursor }, { me, models }) => { + const decodedCursor = decodeCursor(cursor) + const notMine = () => { + return me ? ` AND "userId" <> ${me.id} ` : '' + } + + const items = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT} + FROM "Item" + WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD} + ${notMine()} + ORDER BY created_at DESC + OFFSET $1 + LIMIT ${LIMIT}`, + orderBy: 'ORDER BY "Item"."createdAt" DESC' + }, decodedCursor.offset) + return { + cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + items + } + }, + borderlandItems: async (parent, { cursor }, { me, models }) => { + const decodedCursor = decodeCursor(cursor) + const notMine = () => { + return me ? ` AND "userId" <> ${me.id} ` : '' + } + + const items = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT} + FROM "Item" + WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" < 0 + AND "Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD} + ${notMine()} + ORDER BY created_at DESC + OFFSET $1 + LIMIT ${LIMIT}`, + orderBy: 'ORDER BY "Item"."createdAt" DESC' + }, decodedCursor.offset) + return { + cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + items + } + }, + freebieItems: async (parent, { cursor }, { me, models }) => { + const decodedCursor = decodeCursor(cursor) + + const items = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT} + FROM "Item" + WHERE "Item".freebie + ORDER BY created_at DESC + OFFSET $1 + LIMIT ${LIMIT}`, + orderBy: 'ORDER BY "Item"."createdAt" DESC' + }, decodedCursor.offset) + return { + cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + items + } + }, + getBountiesByUserName: async (parent, { name, cursor, limit }, { me, models }) => { + const decodedCursor = decodeCursor(cursor) + const user = await models.user.findUnique({ where: { name } }) + + if (!user) { + throw new UserInputError('user not found', { + argumentName: 'name' + }) + } + + const items = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT} + FROM "Item" + WHERE "userId" = $1 + AND "bounty" IS NOT NULL + ORDER BY created_at DESC + OFFSET $2 + LIMIT $3`, + orderBy: 'ORDER BY "Item"."createdAt" DESC' + }, user.id, decodedCursor.offset, limit || LIMIT) + + return { + cursor: items.length === (limit || LIMIT) ? nextCursorEncoded(decodedCursor) : null, + items + } + }, + moreFlatComments: async (parent, { sub, cursor, name, sort, within }, { me, models }) => { + const decodedCursor = decodeCursor(cursor) + // HACK we want to optionally include the subName in the query + // but the query planner doesn't like unused parameters + const subArr = sub ? [sub] : [] + + let comments, user + switch (sort) { + case 'recent': + comments = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT} + FROM "Item" + JOIN "Item" root ON "Item"."rootId" = root.id + WHERE "Item"."parentId" IS NOT NULL AND "Item".created_at <= $1 + ${subClause(sub, 3, 'root')} + ${await filterClause(me, models)} + ORDER BY "Item".created_at DESC + OFFSET $2 + LIMIT ${LIMIT}`, + orderBy: 'ORDER BY "Item"."createdAt" DESC' + }, decodedCursor.time, decodedCursor.offset, ...subArr) + break + case 'user': + if (!name) { + throw new UserInputError('must supply name', { argumentName: 'name' }) + } + + user = await models.user.findUnique({ where: { name } }) + if (!user) { + throw new UserInputError('no user has that name', { argumentName: 'name' }) + } + + comments = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT} + FROM "Item" + WHERE "userId" = $1 AND "parentId" IS NOT NULL + AND created_at <= $2 + ${await filterClause(me, models)} + ORDER BY created_at DESC + OFFSET $3 + LIMIT ${LIMIT}`, + orderBy: 'ORDER BY "Item"."createdAt" DESC' + }, user.id, decodedCursor.time, decodedCursor.offset) + break + case 'top': + comments = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT} + FROM "Item" + WHERE "Item"."parentId" IS NOT NULL AND"Item"."deletedAt" IS NULL + AND "Item".created_at <= $1 + ${topClause(within)} + ${await filterClause(me, models)} + ${await topOrderByWeightedSats(me, models)} + OFFSET $2 + LIMIT ${LIMIT}`, + orderBy: await topOrderByWeightedSats(me, models) + }, decodedCursor.time, decodedCursor.offset) + break + default: + throw new UserInputError('invalid sort type', { argumentName: 'sort' }) + } + + return { + cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + comments + } + }, + moreBookmarks: async (parent, { cursor, name }, { me, models }) => { + const decodedCursor = decodeCursor(cursor) + + const user = await models.user.findUnique({ where: { name } }) + if (!user) { + throw new UserInputError('no user has that name', { argumentName: 'name' }) + } + + const items = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT}, "Bookmark".created_at as "bookmarkCreatedAt" + FROM "Item" + JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" AND "Bookmark"."userId" = $1 + AND "Bookmark".created_at <= $2 + ORDER BY "Bookmark".created_at DESC + OFFSET $3 + LIMIT ${LIMIT}`, + orderBy: 'ORDER BY "bookmarkCreatedAt" DESC' + }, user.id, decodedCursor.time, decodedCursor.offset) + + return { + cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + items + } + }, item: getItem, pageTitleAndUnshorted: async (parent, { url }, { models }) => { const res = {} @@ -540,7 +758,7 @@ export default { deleteItem: async (parent, { id }, { me, models }) => { const old = await models.item.findUnique({ where: { id: Number(id) } }) if (Number(old.userId) !== Number(me?.id)) { - throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('item does not belong to you') } const data = { deletedAt: new Date() } @@ -595,9 +813,9 @@ export default { } }, upsertPoll: async (parent, { id, ...data }, { me, models }) => { - const { forward, sub, boost, title, text, options } = data + const { sub, forward, boost, title, text, options } = data if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in') } const optionCount = id @@ -614,14 +832,14 @@ export default { if (forward) { fwdUser = await models.user.findUnique({ where: { name: forward } }) if (!fwdUser) { - throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) } } if (id) { const old = await models.item.findUnique({ where: { id: Number(id) } }) if (Number(old.userId) !== Number(me?.id)) { - throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('item does not belong to you') } const [item] = await serialize(models, models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6, $7) AS "Item"`, @@ -642,13 +860,13 @@ export default { }, upsertJob: async (parent, { id, ...data }, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in to create job') } const { sub, title, company, location, remote, text, url, maxBid, status, logo } = data const fullSub = await models.sub.findUnique({ where: { name: sub } }) if (!fullSub) { - throw new GraphQLError('not a valid sub', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('not a valid sub', { argumentName: 'sub' }) } await ssValidate(jobSchema, data, models) @@ -658,7 +876,7 @@ export default { if (id) { const old = await models.item.findUnique({ where: { id: Number(id) } }) if (Number(old.userId) !== Number(me?.id)) { - throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('item does not belong to you') } ([item] = await serialize(models, models.$queryRaw( @@ -701,7 +919,7 @@ export default { }, pollVote: async (parent, { id }, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in') } await serialize(models, @@ -713,7 +931,7 @@ export default { act: async (parent, { id, sats }, { me, models }) => { // need to make sure we are logged in if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in') } await ssValidate(amountSchema, { amount: sats }) @@ -724,7 +942,7 @@ export default { FROM "Item" WHERE id = $1 AND "userId" = $2`, Number(id), me.id) if (item) { - throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('cannot zap your self') } const [{ item_act: vote }] = await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'TIP', ${Number(sats)})`) @@ -746,7 +964,7 @@ export default { dontLikeThis: async (parent, { id }, { me, models }) => { // need to make sure we are logged in if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in') } // disallow self down votes @@ -755,7 +973,7 @@ export default { FROM "Item" WHERE id = $1 AND "userId" = $2`, Number(id), me.id) if (item) { - throw new GraphQLError('cannot downvote your self', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('cannot downvote your self') } await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST})`) @@ -774,11 +992,11 @@ export default { return item.subName === 'jobs' }, sub: async (item, args, { models }) => { - if (!item.subName && !item.root) { + if (!item.subName) { return null } - return await models.sub.findUnique({ where: { name: item.subName || item.root?.subName } }) + return await models.sub.findUnique({ where: { name: item.subName } }) }, position: async (item, args, { models }) => { if (!item.pinId) { @@ -852,8 +1070,7 @@ export default { if (item.comments) { return item.comments } - - return comments(me, models, item.id, defaultCommentSort(item.pinId, item.bioId, item.createdAt)) + return comments(me, models, item.id, item.pinId ? 'recent' : 'hot') }, wvotes: async (item) => { return item.weightedVotes - item.weightedDownVotes @@ -1009,28 +1226,28 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo // update iff this item belongs to me const old = await models.item.findUnique({ where: { id: Number(id) } }) if (Number(old.userId) !== Number(me?.id)) { - throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('item does not belong to you') } // if it's not the FAQ, not their bio, and older than 10 minutes const user = await models.user.findUnique({ where: { id: me.id } }) if (![349, 76894, 78763, 81862].includes(old.id) && user.bioId !== id && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) { - throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('item can no longer be editted') } if (boost && boost < BOOST_MIN) { - throw new GraphQLError(`boost must be at least ${BOOST_MIN}`, { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) } if (!old.parentId && title.length > MAX_TITLE_LENGTH) { - throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('title too long') } let fwdUser if (forward) { fwdUser = await models.user.findUnique({ where: { name: forward } }) if (!fwdUser) { - throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) } } @@ -1050,22 +1267,22 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in') } if (boost && boost < BOOST_MIN) { - throw new GraphQLError(`boost must be at least ${BOOST_MIN}`, { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) } if (!parentId && title.length > MAX_TITLE_LENGTH) { - throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('title too long') } let fwdUser if (forward) { fwdUser = await models.user.findUnique({ where: { name: forward } }) if (!fwdUser) { - throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) } } diff --git a/api/resolvers/lnurl.js b/api/resolvers/lnurl.js index 82072741..19ea5e6c 100644 --- a/api/resolvers/lnurl.js +++ b/api/resolvers/lnurl.js @@ -1,6 +1,6 @@ import { randomBytes } from 'crypto' import { bech32 } from 'bech32' -import { GraphQLError } from 'graphql' +import { AuthenticationError } from 'apollo-server-micro' function encodedUrl (iurl, tag, k1) { const url = new URL(iurl) @@ -30,7 +30,7 @@ export default { }, createWith: async (parent, args, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } return await models.lnWith.create({ data: { k1: k1(), userId: me.id } }) diff --git a/api/resolvers/message.js b/api/resolvers/message.js index cba48484..ead01c84 100644 --- a/api/resolvers/message.js +++ b/api/resolvers/message.js @@ -1,4 +1,4 @@ -import { GraphQLError } from 'graphql' +import { UserInputError } from 'apollo-server-micro' export default { Query: { @@ -11,7 +11,7 @@ export default { Mutation: { createMessage: async (parent, { text }, { me, models }) => { if (!text) { - throw new GraphQLError('Must have text', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('Must have text', { argumentName: 'text' }) } return await models.message.create({ diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index b53c9e03..3a67e5f0 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -1,4 +1,4 @@ -import { GraphQLError } from 'graphql' +import { AuthenticationError, UserInputError } from 'apollo-server-micro' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { getItem, filterClause } from './item' import { getInvoice } from './wallet' @@ -10,7 +10,7 @@ export default { notifications: async (parent, { cursor, inc }, { me, models }) => { const decodedCursor = decodeCursor(cursor) if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } const meFull = await models.user.findUnique({ where: { id: me.id } }) @@ -228,7 +228,7 @@ export default { Mutation: { savePushSubscription: async (parent, { endpoint, p256dh, auth, oldEndpoint }, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } await ssValidate(pushSubscriptionSchema, { endpoint, p256dh, auth }) @@ -250,12 +250,14 @@ export default { }, deletePushSubscription: async (parent, { endpoint }, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } const subscription = await models.pushSubscription.findFirst({ where: { endpoint, userId: Number(me.id) } }) if (!subscription) { - throw new GraphQLError('endpoint not found', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('endpoint not found', { + argumentName: 'endpoint' + }) } await models.pushSubscription.delete({ where: { id: subscription.id } }) return subscription diff --git a/api/resolvers/referrals.js b/api/resolvers/referrals.js index 2b8cf6bc..ab5b8d66 100644 --- a/api/resolvers/referrals.js +++ b/api/resolvers/referrals.js @@ -1,11 +1,11 @@ -import { GraphQLError } from 'graphql' +import { AuthenticationError } from 'apollo-server-micro' import { withClause, intervalClause, timeUnit } from './growth' export default { Query: { referrals: async (parent, { when }, { models, me }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } const [{ totalSats }] = await models.$queryRaw(` diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js index c2da8779..255a0a72 100644 --- a/api/resolvers/rewards.js +++ b/api/resolvers/rewards.js @@ -1,4 +1,4 @@ -import { GraphQLError } from 'graphql' +import { AuthenticationError } from 'apollo-server-micro' import { amountSchema, ssValidate } from '../../lib/validate' import serialize from './serial' @@ -36,7 +36,7 @@ export default { Mutation: { donateToRewards: async (parent, { sats }, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } await ssValidate(amountSchema, { amount: sats }) diff --git a/api/resolvers/search.js b/api/resolvers/search.js index bb34bc99..fa73c79d 100644 --- a/api/resolvers/search.js +++ b/api/resolvers/search.js @@ -79,7 +79,7 @@ export default { items } }, - search: async (parent, { q: query, sub, cursor, sort, what, when }, { me, models, search }) => { + search: async (parent, { q: query, cursor, sort, what, when }, { me, models, search }) => { const decodedCursor = decodeCursor(cursor) let sitems @@ -105,7 +105,8 @@ export default { const queryArr = query.trim().split(/\s+/) const url = queryArr.find(word => word.startsWith('url:')) const nym = queryArr.find(word => word.startsWith('nym:')) - const exclude = [url, nym] + const sub = queryArr.find(word => word.startsWith('~')) + const exclude = [url, nym, sub] query = queryArr.filter(word => !exclude.includes(word)).join(' ') if (url) { @@ -117,7 +118,7 @@ export default { } if (sub) { - whatArr.push({ match: { 'sub.name': sub } }) + whatArr.push({ match: { 'sub.name': sub.slice(1).toLowerCase() } }) } const sortArr = [] @@ -246,7 +247,7 @@ export default { highlight: { fields: { title: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] }, - text: { number_of_fragments: 5, order: 'score', pre_tags: [':high['], post_tags: [']'] } + text: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] } } } } @@ -265,7 +266,7 @@ export default { const item = await getItem(parent, { id: e._source.id }, { me, models }) item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title - item.searchText = (e.highlight?.text && e.highlight.text.join(' `...` ')) || undefined + item.searchText = (e.highlight?.text && e.highlight.text[0]) || item.text return item }) diff --git a/api/resolvers/serial.js b/api/resolvers/serial.js index d6a94e47..7efdeb7d 100644 --- a/api/resolvers/serial.js +++ b/api/resolvers/serial.js @@ -1,4 +1,4 @@ -const { GraphQLError } = require('graphql') +const { UserInputError } = require('apollo-server-micro') const retry = require('async-retry') async function serialize (models, call) { @@ -12,7 +12,7 @@ async function serialize (models, call) { } catch (error) { console.log(error) if (error.message.includes('SN_INSUFFICIENT_FUNDS')) { - bail(new GraphQLError('insufficient funds', { extensions: { code: 'BAD_INPUT' } })) + bail(new UserInputError('insufficient funds')) } if (error.message.includes('SN_NOT_SERIALIZABLE')) { bail(new Error('wallet balance transaction is not serializable')) diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index 4462cc60..0ec79f84 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -1,8 +1,6 @@ export default { Query: { sub: async (parent, { name }, { models, me }) => { - if (!name) return null - if (me && name === 'jobs') { models.user.update({ where: { diff --git a/api/resolvers/upload.js b/api/resolvers/upload.js index b8bd1e93..def8c906 100644 --- a/api/resolvers/upload.js +++ b/api/resolvers/upload.js @@ -1,4 +1,4 @@ -import { GraphQLError } from 'graphql' +import { AuthenticationError, UserInputError } from 'apollo-server-micro' import AWS from 'aws-sdk' import { IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_TYPES_ALLOW } from '../../lib/constants' @@ -12,19 +12,19 @@ export default { Mutation: { getSignedPOST: async (parent, { type, size, width, height }, { models, me }) => { if (!me) { - throw new GraphQLError('you must be logged in to get a signed url', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in to get a signed url') } if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) { - throw new GraphQLError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`, { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`) } if (size > UPLOAD_SIZE_MAX) { - throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`, { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`) } if (width * height > IMAGE_PIXELS_MAX) { - throw new GraphQLError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`, { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`) } // create upload record diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 661ea79d..245d31db 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -1,10 +1,9 @@ -import { GraphQLError } from 'graphql' +import { AuthenticationError, UserInputError } from 'apollo-server-errors' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { msatsToSats } from '../../lib/format' import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate' import { createMentions, getItem, SELECT, updateItem, filterClause } from './item' import serialize from './serial' -import { dayPivot } from '../../lib/time' export function within (table, within) { let interval = ' AND "' + table + '".created_at >= $1 - INTERVAL ' @@ -54,13 +53,13 @@ export function viewWithin (table, within) { export function withinDate (within) { switch (within) { case 'day': - return dayPivot(new Date(), -1) + return new Date(new Date().setDate(new Date().getDate() - 1)) case 'week': - return dayPivot(new Date(), -7) + return new Date(new Date().setDate(new Date().getDate() - 7)) case 'month': - return dayPivot(new Date(), -30) + return new Date(new Date().setDate(new Date().getDate() - 30)) case 'year': - return dayPivot(new Date(), -365) + return new Date(new Date().setDate(new Date().getDate() - 365)) default: return new Date(0) } @@ -98,7 +97,7 @@ export default { }, settings: async (parent, args, { models, me }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } return await models.user.findUnique({ where: { id: me.id } }) @@ -110,7 +109,7 @@ export default { await models.user.findMany(), nameAvailable: async (parent, { name }, { models, me }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } const user = await models.user.findUnique({ where: { id: me.id } }) @@ -121,7 +120,7 @@ export default { const decodedCursor = decodeCursor(cursor) const users = await models.$queryRaw(` SELECT users.*, floor(sum(msats_spent)/1000) as spent, - sum(posts) as nposts, sum(comments) as ncomments, sum(referrals) as referrals, + sum(posts) as nitems, sum(comments) as ncomments, sum(referrals) as referrals, floor(sum(msats_stacked)/1000) as stacked FROM users LEFT JOIN user_stats_days on users.id = user_stats_days.id @@ -135,15 +134,15 @@ export default { users } }, - topUsers: async (parent, { cursor, when, by }, { models, me }) => { + topUsers: async (parent, { cursor, when, sort }, { models, me }) => { const decodedCursor = decodeCursor(cursor) let users if (when !== 'day') { let column - switch (by) { + switch (sort) { case 'spent': column = 'spent'; break - case 'posts': column = 'nposts'; break + case 'posts': column = 'nitems'; break case 'comments': column = 'ncomments'; break case 'referrals': column = 'referrals'; break default: column = 'stacked'; break @@ -152,7 +151,7 @@ export default { users = await models.$queryRaw(` WITH u AS ( SELECT users.*, floor(sum(msats_spent)/1000) as spent, - sum(posts) as nposts, sum(comments) as ncomments, sum(referrals) as referrals, + sum(posts) as nitems, sum(comments) as ncomments, sum(referrals) as referrals, floor(sum(msats_stacked)/1000) as stacked FROM user_stats_days JOIN users on users.id = user_stats_days.id @@ -171,7 +170,7 @@ export default { } } - if (by === 'spent') { + if (sort === 'spent') { users = await models.$queryRaw(` SELECT users.*, sum(sats_spent) as spent FROM @@ -191,19 +190,19 @@ export default { ORDER BY spent DESC NULLS LAST, users.created_at DESC OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) - } else if (by === 'posts') { + } else if (sort === 'posts') { users = await models.$queryRaw(` - SELECT users.*, count(*) as nposts + SELECT users.*, count(*) as nitems FROM users JOIN "Item" on "Item"."userId" = users.id WHERE "Item".created_at <= $1 AND "Item"."parentId" IS NULL AND NOT users."hideFromTopUsers" ${within('Item', when)} GROUP BY users.id - ORDER BY nposts DESC NULLS LAST, users.created_at DESC + ORDER BY nitems DESC NULLS LAST, users.created_at DESC OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) - } else if (by === 'comments') { + } else if (sort === 'comments') { users = await models.$queryRaw(` SELECT users.*, count(*) as ncomments FROM users @@ -215,7 +214,7 @@ export default { ORDER BY ncomments DESC NULLS LAST, users.created_at DESC OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) - } else if (by === 'referrals') { + } else if (sort === 'referrals') { users = await models.$queryRaw(` SELECT users.*, count(*) as referrals FROM users @@ -428,24 +427,23 @@ export default { Mutation: { setName: async (parent, data, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } await ssValidate(userSchema, data, models) try { await models.user.update({ where: { id: me.id }, data }) - return data.name } catch (error) { if (error.code === 'P2002') { - throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('name taken') } throw error } }, setSettings: async (parent, { nostrRelays, ...data }, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } await ssValidate(settingsSchema, { nostrRelays, ...data }) @@ -471,7 +469,7 @@ export default { }, setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } await models.user.update({ where: { id: me.id }, data: { upvotePopover, tipPopover } }) @@ -480,7 +478,7 @@ export default { }, setPhoto: async (parent, { photoId }, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } await models.user.update({ @@ -492,7 +490,7 @@ export default { }, upsertBio: async (parent, { bio }, { me, models }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } await ssValidate(bioSchema, { bio }) @@ -512,7 +510,7 @@ export default { }, unlinkAuth: async (parent, { authType }, { models, me }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } let user @@ -520,7 +518,7 @@ export default { user = await models.user.findUnique({ where: { id: me.id } }) const account = await models.account.findFirst({ where: { userId: me.id, providerId: authType } }) if (!account) { - throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('no such account') } await models.account.delete({ where: { id: account.id } }) } else if (authType === 'lightning') { @@ -530,14 +528,14 @@ export default { } else if (authType === 'email') { user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } }) } else { - throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('no such account') } return await authMethods(user, undefined, { models, me }) }, linkUnverifiedEmail: async (parent, { email }, { models, me }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + throw new AuthenticationError('you must be logged in') } await ssValidate(emailSchema, { email }) @@ -549,7 +547,7 @@ export default { }) } catch (error) { if (error.code === 'P2002') { - throw new GraphQLError('email taken', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('email taken') } throw error } @@ -583,20 +581,6 @@ export default { return user.nitems } - return await models.item.count({ - where: { - userId: user.id, - createdAt: { - gte: withinDate(when) - } - } - }) - }, - nposts: async (user, { when }, { models }) => { - if (typeof user.nposts === 'number') { - return user.nposts - } - return await models.item.count({ where: { userId: user.id, diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index d72c9ccf..d4f3ebe6 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,5 +1,5 @@ import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service' -import { GraphQLError } from 'graphql' +import { UserInputError, AuthenticationError } from 'apollo-server-micro' import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import lnpr from 'bolt11' @@ -10,7 +10,7 @@ import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../l export async function getInvoice (parent, { id }, { me, models }) { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in') } const inv = await models.invoice.findUnique({ @@ -23,7 +23,7 @@ export async function getInvoice (parent, { id }, { me, models }) { }) if (inv.user.id !== me.id) { - throw new GraphQLError('not ur invoice', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('not ur invoice') } return inv @@ -34,7 +34,7 @@ export default { invoice: getInvoice, withdrawl: async (parent, { id }, { me, models, lnd }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in') } const wdrwl = await models.withdrawl.findUnique({ @@ -47,7 +47,7 @@ export default { }) if (wdrwl.user.id !== me.id) { - throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('not ur withdrawal') } return wdrwl @@ -58,7 +58,7 @@ export default { walletHistory: async (parent, { cursor, inc }, { me, models, lnd }) => { const decodedCursor = decodeCursor(cursor) if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in') } const include = new Set(inc?.split(',')) @@ -191,7 +191,7 @@ export default { Mutation: { createInvoice: async (parent, { amount }, { me, models, lnd }) => { if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + throw new AuthenticationError('you must be logged in') } await ssValidate(amountSchema, { amount }) @@ -239,7 +239,9 @@ export default { const milliamount = amount * 1000 // check that amount is within min and max sendable if (milliamount < res1.minSendable || milliamount > res1.maxSendable) { - throw new GraphQLError(`amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`, { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError( + `amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`, + { argumentName: 'amount' }) } const callback = new URL(res1.callback) @@ -309,11 +311,11 @@ async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd decoded = await decodePaymentRequest({ lnd, request: invoice }) } catch (error) { console.log(error) - throw new GraphQLError('could not decode invoice', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('could not decode invoice') } if (!decoded.mtokens || BigInt(decoded.mtokens) <= 0) { - throw new GraphQLError('your invoice must specify an amount', { extensions: { code: 'BAD_INPUT' } }) + throw new UserInputError('your invoice must specify an amount') } const msatsFee = Number(maxFee) * 1000 diff --git a/api/ssrApollo.js b/api/ssrApollo.js index c9fac53b..bd70d679 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -31,38 +31,16 @@ export default async function getSSRApolloClient (req, me = null) { slashtags } }), - cache: new InMemoryCache({ - freezeResults: true - }), - assumeImmutableResults: true, - defaultOptions: { - watchQuery: { - fetchPolicy: 'cache-only', - nextFetchPolicy: 'cache-only', - canonizeResults: true, - ssr: true - }, - query: { - fetchPolicy: 'cache-first', - nextFetchPolicy: 'cache-only', - canonizeResults: true, - ssr: true - } - } + cache: new InMemoryCache() }) + await client.clearStore() return client } -export function getGetServerSideProps (queryOrFunc, variablesOrFunc = null, notFoundFunc, requireVar) { +export function getGetServerSideProps (query, variables = null, notFoundFunc, requireVar) { return async function ({ req, query: params }) { const { nodata, ...realParams } = params - // we want to use client-side cache - if (nodata) return { props: { } } - - const variables = typeof variablesOrFunc === 'function' ? variablesOrFunc(realParams) : variablesOrFunc const vars = { ...realParams, ...variables } - const query = typeof queryOrFunc === 'function' ? queryOrFunc(vars) : queryOrFunc - const client = await getSSRApolloClient(req) const { data: { me } } = await client.query({ @@ -74,6 +52,20 @@ export function getGetServerSideProps (queryOrFunc, variablesOrFunc = null, notF query: PRICE, variables: { fiatCurrency: me?.fiatCurrency } }) + // we want to use client-side cache + if (nodata && query) { + return { + props: { + me, + price, + apollo: { + query: print(query), + variables: vars + } + } + } + } + if (requireVar && !vars[requireVar]) { return { notFound: true @@ -99,7 +91,7 @@ export function getGetServerSideProps (queryOrFunc, variablesOrFunc = null, notF throw err } - if (error || !data || (notFoundFunc && notFoundFunc(data, vars))) { + if (error || !data || (notFoundFunc && notFoundFunc(data))) { return { notFound: true } @@ -118,7 +110,7 @@ export function getGetServerSideProps (queryOrFunc, variablesOrFunc = null, notF ...props, me, price, - ssrData: data + data } } } diff --git a/api/typeDefs/admin.js b/api/typeDefs/admin.js index 8d82f172..704219bc 100644 --- a/api/typeDefs/admin.js +++ b/api/typeDefs/admin.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` extend type Query { diff --git a/api/typeDefs/growth.js b/api/typeDefs/growth.js index aa53b9df..2b41fa8f 100644 --- a/api/typeDefs/growth.js +++ b/api/typeDefs/growth.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` type NameValue { diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index 3c659be7..0745be29 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' import user from './user' import message from './message' diff --git a/api/typeDefs/invite.js b/api/typeDefs/invite.js index 217d15c7..8d377f84 100644 --- a/api/typeDefs/invite.js +++ b/api/typeDefs/invite.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` extend type Query { diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 7b44c4f0..d31c75e8 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -1,16 +1,25 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` extend type Query { - items(sub: String, sort: String, type: String, cursor: String, name: String, when: String, by: String, limit: Int): Items + items(sub: String, sort: String, type: String, cursor: String, name: String, within: String): Items + moreFlatComments(sub: String, sort: String!, cursor: String, name: String, within: String): Comments + moreBookmarks(cursor: String, name: String!): Items item(id: ID!): Item comments(id: ID!, sort: String): [Item!]! pageTitleAndUnshorted(url: String!): TitleUnshorted dupes(url: String!): [Item!] related(cursor: String, title: String, id: ID, minMatch: String, limit: Int): Items - search(q: String, sub: String, cursor: String, what: String, sort: String, when: String): Items + allItems(cursor: String): Items + getBountiesByUserName(name: String!, cursor: String, , limit: Int): Items + search(q: String, cursor: String, what: String, sort: String, when: String): Items auctionPosition(sub: String, id: ID, bid: Int!): Int! itemRepetition(parentId: ID): Int! + outlawedItems(cursor: String): Items + borderlandItems(cursor: String): Items + freebieItems(cursor: String): Items + topItems(cursor: String, sub: String, sort: String, when: String): Items + topComments(cursor: String, sub: String, sort: String, when: String): Comments } type TitleUnshorted { diff --git a/api/typeDefs/lnurl.js b/api/typeDefs/lnurl.js index ce1b1dac..9ef3d9d9 100644 --- a/api/typeDefs/lnurl.js +++ b/api/typeDefs/lnurl.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` extend type Query { diff --git a/api/typeDefs/message.js b/api/typeDefs/message.js index a08611b1..00778f22 100644 --- a/api/typeDefs/message.js +++ b/api/typeDefs/message.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` extend type Query { diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 80831d9e..2045daef 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` extend type Query { @@ -11,39 +11,33 @@ export default gql` } type Votification { - id: ID! earnedSats: Int! item: Item! sortTime: String! } type Reply { - id: ID! item: Item! sortTime: String! } type Mention { - id: ID! mention: Boolean! item: Item! sortTime: String! } type Invitification { - id: ID! invite: Invite! sortTime: String! } type JobChanged { - id: ID! item: Item! sortTime: String! } type EarnSources { - id: ID! posts: Int! comments: Int! tipPosts: Int! @@ -51,27 +45,24 @@ export default gql` } type Streak { - id: ID! sortTime: String! days: Int + id: ID! } type Earn { - id: ID! earnedSats: Int! sortTime: String! sources: EarnSources } type InvoicePaid { - id: ID! earnedSats: Int! invoice: Invoice! sortTime: String! } type Referral { - id: ID! sortTime: String! } diff --git a/api/typeDefs/price.js b/api/typeDefs/price.js index 5ffb8e36..4bfdc295 100644 --- a/api/typeDefs/price.js +++ b/api/typeDefs/price.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` extend type Query { diff --git a/api/typeDefs/referrals.js b/api/typeDefs/referrals.js index ab636c9d..5309f37d 100644 --- a/api/typeDefs/referrals.js +++ b/api/typeDefs/referrals.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` extend type Query { diff --git a/api/typeDefs/rewards.js b/api/typeDefs/rewards.js index 85ca1ba4..e9776c20 100644 --- a/api/typeDefs/rewards.js +++ b/api/typeDefs/rewards.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` extend type Query { diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index e279b6fb..4a96855c 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -1,8 +1,8 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` extend type Query { - sub(name: String): Sub + sub(name: String!): Sub subLatestPost(name: String!): String } diff --git a/api/typeDefs/upload.js b/api/typeDefs/upload.js index f462458e..9ea1621b 100644 --- a/api/typeDefs/upload.js +++ b/api/typeDefs/upload.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` scalar JSONObject diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 04a0ca43..327ea9f9 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` extend type Query { @@ -7,7 +7,7 @@ export default gql` user(name: String!): User users: [User!] nameAvailable(name: String!): Boolean! - topUsers(cursor: String, when: String, by: String): Users + topUsers(cursor: String, when: String, sort: String): Users topCowboys(cursor: String): Users searchUsers(q: String!, limit: Int, similarity: Float): [User!]! hasNewNotes: Boolean! @@ -19,7 +19,7 @@ export default gql` } extend type Mutation { - setName(name: String!): String + setName(name: String!): Boolean setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: Boolean!, noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!, noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!, @@ -45,7 +45,6 @@ export default gql` createdAt: String! name: String nitems(when: String): Int! - nposts(when: String): Int! ncomments(when: String): Int! nbookmarks(when: String): Int! stacked(when: String): Int! diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 54ad278f..ff06a926 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' export default gql` extend type Query { diff --git a/components/avatar.js b/components/avatar.js index 7dec407b..d9fd394a 100644 --- a/components/avatar.js +++ b/components/avatar.js @@ -1,72 +1,74 @@ import { useRef, useState } from 'react' import AvatarEditor from 'react-avatar-editor' -import { Button, Form as BootstrapForm } from 'react-bootstrap' +import { Button, Modal, Form as BootstrapForm } from 'react-bootstrap' import Upload from './upload' import EditImage from '../svgs/image-edit-fill.svg' import Moon from '../svgs/moon-fill.svg' -import { useShowModal } from './modal' export default function Avatar ({ onSuccess }) { const [uploading, setUploading] = useState() + const [editProps, setEditProps] = useState() const ref = useRef() const [scale, setScale] = useState(1) - const showModal = useShowModal() - - const Body = ({ onClose, file, upload }) => { - return ( -
- - - setScale(parseFloat(e.target.value))} - min={1} max={2} step='0.05' - defaultValue={scale} custom - /> - - -
- ) - } return ( - -
- {uploading - ? - : } -
} - onError={e => { - console.log(e) - setUploading(false) - }} - onSelect={(file, upload) => { - showModal(onClose => ) - }} - onSuccess={async key => { - onSuccess && onSuccess(key) - setUploading(false) - }} - onStarted={() => { - setUploading(true) - }} - /> + <> + setEditProps(null)} + > +
setEditProps(null)}>X
+ + + + setScale(parseFloat(e.target.value))} + min={1} max={2} step='0.05' + defaultValue={scale} custom + /> + + + +
+ +
+ {uploading + ? + : } +
} + onError={e => { + console.log(e) + setUploading(false) + }} + onSelect={(file, upload) => { + setEditProps({ file, upload }) + }} + onSuccess={async key => { + onSuccess && onSuccess(key) + setUploading(false) + }} + onStarted={() => { + setUploading(true) + }} + /> + ) } diff --git a/components/bookmark.js b/components/bookmark.js index 55e8d024..dc689ccc 100644 --- a/components/bookmark.js +++ b/components/bookmark.js @@ -1,5 +1,5 @@ import { useMutation } from '@apollo/client' -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' import { Dropdown } from 'react-bootstrap' export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) { diff --git a/components/bounty-form.js b/components/bounty-form.js index 93b18852..bdc35f63 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -1,6 +1,7 @@ import { Form, Input, MarkdownInput, SubmitButton } from '../components/form' import { useRouter } from 'next/router' import { gql, useApolloClient, useMutation } from '@apollo/client' +import TextareaAutosize from 'react-textarea-autosize' import Countdown from './countdown' import AdvPostForm, { AdvPostInitial } from './adv-post-form' import FeeButton, { EditFeeButton } from './fee-button' @@ -99,6 +100,7 @@ export function BountyForm ({ } name='text' + as={TextareaAutosize} minRows={6} hint={ editThreshold diff --git a/components/comment-edit.js b/components/comment-edit.js index f9ab8156..037b113d 100644 --- a/components/comment-edit.js +++ b/components/comment-edit.js @@ -1,6 +1,7 @@ import { Form, MarkdownInput, SubmitButton } from '../components/form' import { gql, useMutation } from '@apollo/client' import styles from './reply.module.css' +import TextareaAutosize from 'react-textarea-autosize' import { EditFeeButton } from './fee-button' import { Button } from 'react-bootstrap' import Delete from './delete' @@ -46,6 +47,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc > ( <> \ - - parent + + parent ) @@ -38,12 +38,12 @@ function Parent ({ item, rootText }) { <> {Number(root.id) !== Number(item.parentId) && } \ - - {rootText || 'on:'} {root?.title} + + {rootText || 'on:'} {root?.title} {root.subName && - {' '}{root.subName} + {' '}{root.subName} } ) @@ -54,42 +54,32 @@ const truncateString = (string = '', maxLength = 140) => ? `${string.substring(0, maxLength)} […]` : string -export function CommentFlat ({ item, rank, ...props }) { +export function CommentFlat ({ item, ...props }) { const router = useRouter() - const [href, as] = useMemo(() => { - if (item.path.split('.').length > COMMENT_DEPTH_LIMIT + 1) { - return [{ - pathname: '/items/[id]', - query: { id: item.parentId, commentId: item.id } - }, `/items/${item.parentId}`] - } else { - return [{ - pathname: '/items/[id]', - query: { id: item.root.id, commentId: item.id } - }, `/items/${item.root.id}`] - } - }, [item?.id]) - return ( - <> - {rank - ? ( -
- {rank} -
) - :
} -
{ - if (ignoreClick(e)) return - router.push(href, as) - }} - > - - - -
- +
{ + if (ignoreClick(e)) { + return + } + if (item.path.split('.').length > COMMENT_DEPTH_LIMIT + 1) { + router.push({ + pathname: '/items/[id]', + query: { id: item.parentId, commentId: item.id } + }, `/items/${item.parentId}`) + } else { + router.push({ + pathname: '/items/[id]', + query: { id: item.root.id, commentId: item.id } + }, `/items/${item.root.id}`) + } + }} + > + + + +
) } @@ -193,26 +183,24 @@ export default function Comment ({ )}
- {collapse !== 'yep' && ( - bottomedOut - ? - : ( -
- {!noReply && - - {root.bounty && !bountyPaid && } - } - {children} -
- {item.comments && !noComments - ? item.comments.map((item) => ( - - )) - : null} -
+ {bottomedOut + ? + : ( +
+ {!noReply && + + {root.bounty && !bountyPaid && } + } + {children} +
+ {item.comments && !noComments + ? item.comments.map((item) => ( + + )) + : null}
- ) - )} +
+ )}
) } @@ -220,8 +208,8 @@ export default function Comment ({ function DepthLimit ({ item }) { if (item.ncomments > 0) { return ( - - view replies + + view replies ) } diff --git a/components/comments-flat.js b/components/comments-flat.js new file mode 100644 index 00000000..bfbbe6f6 --- /dev/null +++ b/components/comments-flat.js @@ -0,0 +1,42 @@ +import { useQuery } from '@apollo/client' +import { MORE_FLAT_COMMENTS } from '../fragments/comments' +import { CommentFlat, CommentSkeleton } from './comment' +import MoreFooter from './more-footer' + +export default function CommentsFlat ({ variables, query, destructureData, comments, cursor, ...props }) { + const { data, fetchMore } = useQuery(query || MORE_FLAT_COMMENTS, { + variables + }) + + if (!data && !comments) { + return + } + + if (data) { + if (destructureData) { + ({ comments, cursor } = destructureData(data)) + } else { + ({ moreFlatComments: { comments, cursor } } = data) + } + } + + return ( + <> + {comments.map(item => + + )} + + + ) +} + +function CommentsFlatSkeleton () { + const comments = new Array(21).fill(null) + + return ( +
{comments.map((_, i) => ( + + ))} +
+ ) +} diff --git a/components/comments.js b/components/comments.js index eb05d250..df531886 100644 --- a/components/comments.js +++ b/components/comments.js @@ -1,15 +1,14 @@ import { gql, useApolloClient, useLazyQuery } from '@apollo/client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import Comment, { CommentSkeleton } from './comment' import styles from './header.module.css' import { Nav, Navbar } from 'react-bootstrap' import { COMMENTS_QUERY } from '../fragments/items' import { COMMENTS } from '../fragments/comments' import { abbrNum } from '../lib/format' -import { defaultCommentSort } from '../lib/item' -export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { - const [sort, setSort] = useState(defaultCommentSort(pinned, bio, parentCreatedAt)) +export function CommentsHeader ({ handleSort, pinned, commentSats }) { + const [sort, setSort] = useState(pinned ? 'recent' : 'hot') const getHandleClick = sort => { return () => { @@ -61,12 +60,19 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm ) } -export default function Comments ({ parentId, pinned, bio, parentCreatedAt, commentSats, comments, ...props }) { +export default function Comments ({ parentId, pinned, commentSats, comments, ...props }) { const client = useApolloClient() - + useEffect(() => { + const hash = window.location.hash + if (hash) { + try { + document.querySelector(hash).scrollIntoView({ behavior: 'smooth' }) + } catch {} + } + }, [typeof window !== 'undefined' && window.location.hash]) const [loading, setLoading] = useState() const [getComments] = useLazyQuery(COMMENTS_QUERY, { - fetchPolicy: 'cache-first', + fetchPolicy: 'network-only', onCompleted: data => { client.writeFragment({ id: `Item:${parentId}`, @@ -91,8 +97,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm <> {comments.length ? { + commentSats={commentSats} pinned={pinned} handleSort={sort => { setLoading(true) getComments({ variables: { id: parentId, sort } }) }} diff --git a/components/countdown.js b/components/countdown.js index 31b77e1f..cc7ef4da 100644 --- a/components/countdown.js +++ b/components/countdown.js @@ -5,7 +5,7 @@ export default function SimpleCountdown ({ className, onComplete, date }) { {props.formatted.minutes}:{props.formatted.seconds}} + renderer={props => {props.formatted.minutes}:{props.formatted.seconds}} onComplete={onComplete} /> diff --git a/components/dark-mode.js b/components/dark-mode.js deleted file mode 100644 index fe6e97f0..00000000 --- a/components/dark-mode.js +++ /dev/null @@ -1,17 +0,0 @@ -import { useEffect, useState } from 'react' -import { getTheme, listenForThemeChange, setTheme } from '../public/dark' - -export default function useDarkMode () { - const [dark, setDark] = useState() - - useEffect(() => { - const { user, dark } = getTheme() - setDark({ user, dark }) - listenForThemeChange(setDark) - }, []) - - return [dark?.dark, () => { - setTheme(!dark.dark) - setDark({ user: true, dark: !dark.dark }) - }] -} diff --git a/components/delete.js b/components/delete.js index 4b22f2b3..bdb5e123 100644 --- a/components/delete.js +++ b/components/delete.js @@ -1,5 +1,5 @@ import { useMutation } from '@apollo/client' -import { gql } from 'graphql-tag' +import { gql } from 'apollo-server-micro' import { useState } from 'react' import { Alert, Button, Dropdown } from 'react-bootstrap' import { useShowModal } from './modal' diff --git a/components/discussion-form.js b/components/discussion-form.js index ce13dae7..e58c9559 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -1,6 +1,7 @@ import { Form, Input, MarkdownInput, SubmitButton } from '../components/form' import { useRouter } from 'next/router' import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client' +import TextareaAutosize from 'react-textarea-autosize' import Countdown from './countdown' import AdvPostForm, { AdvPostInitial } from './adv-post-form' import FeeButton, { EditFeeButton } from './fee-button' @@ -35,14 +36,16 @@ export function DiscussionForm ({ ) const [getRelated, { data: relatedData }] = useLazyQuery(gql` - ${ITEM_FIELDS} - query related($title: String!) { - related(title: $title, minMatch: "75%", limit: 3) { - items { - ...ItemFields - } + ${ITEM_FIELDS} + query related($title: String!) { + related(title: $title, minMatch: "75%", limit: 3) { + items { + ...ItemFields } - }`) + } + }`, { + fetchPolicy: 'network-only' + }) const related = relatedData?.related?.items || [] @@ -93,6 +96,7 @@ export function DiscussionForm ({ topLevel label={<>{textLabel} optional} name='text' + as={TextareaAutosize} minRows={6} hint={editThreshold ?
diff --git a/components/error-boundary.js b/components/error-boundary.js index f5a637ac..d411f605 100644 --- a/components/error-boundary.js +++ b/components/error-boundary.js @@ -1,5 +1,5 @@ import { Component } from 'react' -import { StaticLayout } from './layout' +import LayoutStatic from './layout-static' import styles from '../styles/404.module.css' class ErrorBoundary extends Component { @@ -25,10 +25,10 @@ class ErrorBoundary extends Component { if (this.state.hasError) { // You can render any custom fallback UI return ( - - + +

something went wrong

-
+ ) } diff --git a/components/fee-button.js b/components/fee-button.js index a889fbce..6f571321 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -43,7 +43,7 @@ export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, const query = parentId ? gql`{ itemRepetition(parentId: "${parentId}") }` : gql`{ itemRepetition }` - const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' }) + const { data } = useQuery(query, { pollInterval: 1000 }) const repetition = data?.itemRepetition || 0 const formik = useFormikContext() const boost = formik?.values?.boost || 0 diff --git a/components/footer-rewards.js b/components/footer-rewards.js index ea81b920..b12fdc12 100644 --- a/components/footer-rewards.js +++ b/components/footer-rewards.js @@ -10,12 +10,14 @@ const REWARDS = gql` }` export default function Rewards () { - const { data } = useQuery(REWARDS, { pollInterval: 60000, nextFetchPolicy: 'cache-and-network' }) + const { data } = useQuery(REWARDS, { pollInterval: 60000, fetchPolicy: 'cache-and-network' }) const total = data?.expectedRewards?.total return ( - - {total ? : 'rewards'} + + + {total ? : 'rewards'} + ) } diff --git a/components/footer.js b/components/footer.js index 32cb3dfd..fedb35a6 100644 --- a/components/footer.js +++ b/components/footer.js @@ -1,9 +1,12 @@ +import { useQuery } from '@apollo/client' +import gql from 'graphql-tag' import { Container, OverlayTrigger, Popover } from 'react-bootstrap' import { CopyInput } from './form' import styles from './footer.module.css' import Texas from '../svgs/texas.svg' import Github from '../svgs/github-fill.svg' import Link from 'next/link' +import useDarkMode from 'use-dark-mode' import Sun from '../svgs/sun-fill.svg' import Moon from '../svgs/moon-fill.svg' import No from '../svgs/no.svg' @@ -11,7 +14,70 @@ import Bolt from '../svgs/bolt.svg' import Amboss from '../svgs/amboss.svg' import { useEffect, useState } from 'react' import Rewards from './footer-rewards' -import useDarkMode from './dark-mode' + +// XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +// if you update this you need to update /public/darkmode +// XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +const COLORS = { + light: { + body: '#f5f5f7', + color: '#212529', + navbarVariant: 'light', + navLink: 'rgba(0, 0, 0, 0.55)', + navLinkFocus: 'rgba(0, 0, 0, 0.7)', + navLinkActive: 'rgba(0, 0, 0, 0.9)', + borderColor: '#ced4da', + inputBg: '#ffffff', + inputDisabledBg: '#e9ecef', + dropdownItemColor: 'rgba(0, 0, 0, 0.7)', + dropdownItemColorHover: 'rgba(0, 0, 0, 0.9)', + commentBg: 'rgba(0, 0, 0, 0.03)', + clickToContextColor: 'rgba(0, 0, 0, 0.07)', + brandColor: 'rgba(0, 0, 0, 0.9)', + grey: '#707070', + link: '#007cbe', + toolbarActive: 'rgba(0, 0, 0, 0.10)', + toolbarHover: 'rgba(0, 0, 0, 0.20)', + toolbar: '#ffffff', + quoteBar: 'rgb(206, 208, 212)', + quoteColor: 'rgb(101, 103, 107)', + linkHover: '#004a72', + linkVisited: '#537587' + }, + dark: { + body: '#000000', + inputBg: '#000000', + inputDisabledBg: '#000000', + navLink: 'rgba(255, 255, 255, 0.55)', + navLinkFocus: 'rgba(255, 255, 255, 0.75)', + navLinkActive: 'rgba(255, 255, 255, 0.9)', + borderColor: 'rgba(255, 255, 255, 0.5)', + dropdownItemColor: 'rgba(255, 255, 255, 0.7)', + dropdownItemColorHover: 'rgba(255, 255, 255, 0.9)', + commentBg: 'rgba(255, 255, 255, 0.04)', + clickToContextColor: 'rgba(255, 255, 255, 0.2)', + color: '#f8f9fa', + brandColor: 'var(--primary)', + grey: '#969696', + link: '#2e99d1', + toolbarActive: 'rgba(255, 255, 255, 0.10)', + toolbarHover: 'rgba(255, 255, 255, 0.20)', + toolbar: '#3e3f3f', + quoteBar: 'rgb(158, 159, 163)', + quoteColor: 'rgb(141, 144, 150)', + linkHover: '#007cbe', + linkVisited: '#56798E' + } +} + +const handleThemeChange = (dark) => { + const root = window.document.documentElement + const colors = COLORS[dark ? 'dark' : 'light'] + Object.entries(colors).forEach(([varName, value]) => { + const cssVarName = `--theme-${varName}` + root.style.setProperty(cssVarName, value) + }) +} const RssPopover = ( @@ -113,19 +179,33 @@ const AnalyticsPopover = ( visitors \ - - stackers + + + stackers + ) -export default function Footer ({ links = true }) { - const [darkMode, darkModeToggle] = useDarkMode() +export default function Footer ({ noLinks }) { + const query = gql` + { + connectAddress + } + ` + const { data } = useQuery(query, { fetchPolicy: 'cache-first' }) + const darkMode = useDarkMode(false, { + // set this so it doesn't try to use classes + onChange: handleThemeChange + }) + + const [mounted, setMounted] = useState() const [lightning, setLightning] = useState(undefined) useEffect(() => { + setMounted(true) setLightning(localStorage.getItem('lnAnimate') || 'yes') }, []) @@ -139,7 +219,7 @@ export default function Footer ({ links = true }) { } } - const DarkModeIcon = darkMode ? Sun : Moon + const DarkModeIcon = darkMode.value ? Sun : Moon const LnIcon = lightning === 'yes' ? No : Bolt const version = process.env.NEXT_PUBLIC_COMMIT_HASH @@ -147,12 +227,13 @@ export default function Footer ({ links = true }) { return (