diff --git a/.npmrc b/.npmrc index b77945ac..93b53126 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,2 @@ 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 8b176d7b..6f9bac8d 100644 --- a/api/resolvers/invite.js +++ b/api/resolvers/invite.js @@ -1,11 +1,11 @@ -import { AuthenticationError } from 'apollo-server-micro' +import { GraphQLError } from 'graphql' import { inviteSchema, ssValidate } from '../../lib/validate' export default { Query: { invites: async (parent, args, { me, models }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } return await models.invite.findMany({ @@ -29,7 +29,7 @@ export default { Mutation: { createInvite: async (parent, { gift, limit }, { me, models }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } await ssValidate(inviteSchema, { gift, limit }) @@ -40,7 +40,7 @@ export default { }, revokeInvite: async (parent, { id }, { me, models }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } return await models.invite.update({ diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 4967a4d0..43dff025 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -1,4 +1,4 @@ -import { UserInputError, AuthenticationError } from 'apollo-server-micro' +import { GraphQLError } from 'graphql' import { ensureProtocol, removeTracking } from '../../lib/url' import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' @@ -6,7 +6,8 @@ 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 + MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, + DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY } from '../../lib/constants' import { msatsToSats } from '../../lib/format' import { parse } from 'tldts' @@ -14,6 +15,26 @@ 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 @@ -53,9 +74,9 @@ export async function getItem (parent, { id }, { me, models }) { return item } -function topClause (within) { - let interval = ' AND "Item".created_at >= $1 - INTERVAL ' - switch (within) { +function whenClause (when, type) { + let interval = ` AND "${type === 'bookmarks' ? 'Bookmark' : 'Item'}".created_at >= $1 - INTERVAL ` + switch (when) { case 'forever': interval = '' break @@ -75,14 +96,16 @@ function topClause (within) { return interval } -async function topOrderClause (sort, me, models) { - switch (sort) { +const orderByClause = async (by, me, models, type) => { + switch (by) { case 'comments': - return 'ORDER BY ncomments DESC' + return 'ORDER BY "Item".ncomments DESC' case 'sats': - return 'ORDER BY msats DESC' - default: + return 'ORDER BY "Item".msats DESC' + case 'votes': return await topOrderByWeightedSats(me, models) + default: + return `ORDER BY "${type === 'bookmarks' ? 'Bookmark' : 'Item'}".created_at DESC` } } @@ -112,26 +135,17 @@ export async function joinSatRankView (me, models) { return 'JOIN sat_rank_tender_view ON "Item".id = sat_rank_tender_view.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 '' +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} ` } - // always include if it's mine - clause += ` OR "Item"."userId" = ${me.id}` + return '' } - // 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) { @@ -162,20 +176,33 @@ export async function filterClause (me, models) { return clause } -function recentClause (type) { +function typeClause (type) { switch (type) { case 'links': - return ' AND url IS NOT NULL' + return ' AND "Item".url IS NOT NULL AND "Item"."parentId" IS NULL' case 'discussions': - return ' AND url IS NULL AND bio = false AND "pollCost" IS NULL' + return ' AND "Item".url IS NULL AND "Item".bio = false AND "Item"."pollCost" IS NULL AND "Item"."parentId" IS NULL' case 'polls': - return ' AND "pollCost" IS NOT NULL' + return ' AND "Item"."pollCost" IS NOT NULL AND "Item"."parentId" IS NULL' case 'bios': - return ' AND bio = true' + return ' AND "Item".bio = true AND "Item"."parentId" IS NULL' case 'bounties': - return ' AND bounty IS NOT NULL' - default: + 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\'' + default: + return ' AND "Item"."parentId" IS NULL' } } @@ -218,6 +245,28 @@ 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 }) => { @@ -228,62 +277,9 @@ export default { return count }, - topItems: async (parent, { sub, cursor, sort, when }, { me, models }) => { + items: async (parent, { sub, sort, type, cursor, name, when, by, limit = LIMIT }, { me, models }) => { const decodedCursor = decodeCursor(cursor) - 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\' ' - } + let items, user, pins, subFull, table // HACK we want to optionally include the subName in the query // but the query planner doesn't like unused parameters @@ -292,29 +288,32 @@ export default { switch (sort) { case 'user': if (!name) { - throw new UserInputError('must supply name', { argumentName: 'name' }) + throw new GraphQLError('must supply name', { extensions: { code: 'BAD_INPUT' } }) } user = await models.user.findUnique({ where: { name } }) if (!user) { - throw new UserInputError('no user has that name', { argumentName: 'name' }) + throw new GraphQLError('no user has that name', { extensions: { code: 'BAD_INPUT' } }) } + table = type === 'bookmarks' ? 'Bookmark' : 'Item' items = await itemQueryWithMeta({ me, models, query: ` ${SELECT} - 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 + ${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)} OFFSET $3 - LIMIT ${LIMIT}`, - orderBy: 'ORDER BY "Item"."createdAt" DESC' - }, user.id, decodedCursor.time, decodedCursor.offset) + LIMIT $4`, + orderBy: await orderByClause(by, me, models, type) + }, decodedCursor.time, user.id, decodedCursor.offset, limit, ...subArr) break case 'recent': items = await itemQueryWithMeta({ @@ -322,17 +321,17 @@ export default { models, query: ` ${SELECT} - 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 + ${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 OFFSET $2 - LIMIT ${LIMIT}`, + LIMIT $3`, orderBy: 'ORDER BY "Item"."createdAt" DESC' - }, decodedCursor.time, decodedCursor.offset, ...subArr) + }, decodedCursor.time, decodedCursor.offset, limit, ...subArr) break case 'top': items = await itemQueryWithMeta({ @@ -340,16 +339,18 @@ export default { models, query: ` ${SELECT} - 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)} + ${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)} OFFSET $2 - LIMIT ${LIMIT}`, - orderBy: await topOrderByWeightedSats(me, models) - }, decodedCursor.time, decodedCursor.offset) + LIMIT $3`, + orderBy: await orderByClause(by || 'votes', me, models, type) + }, decodedCursor.time, decodedCursor.offset, limit, ...subArr) break default: // sub so we know the default ranking @@ -372,13 +373,13 @@ export default { FROM "Item" WHERE "parentId" IS NULL AND created_at <= $1 AND "pinId" IS NULL - ${subClause(sub, 3)} + ${subClause(sub, 4)} AND status IN ('ACTIVE', 'NOSATS') ORDER BY group_rank, rank OFFSET $2 - LIMIT ${LIMIT}`, + LIMIT $3`, orderBy: 'ORDER BY group_rank, rank' - }, decodedCursor.time, decodedCursor.offset, ...subArr) + }, decodedCursor.time, decodedCursor.offset, limit, ...subArr) break default: items = await itemQueryWithMeta({ @@ -388,12 +389,12 @@ export default { ${SELECT}, rank FROM "Item" ${await joinSatRankView(me, models)} - ${subClause(sub, 2, 'Item', true)} + ${subClause(sub, 3, 'Item', true)} ORDER BY rank ASC OFFSET $1 - LIMIT ${LIMIT}`, + LIMIT $2`, orderBy: 'ORDER BY rank ASC' - }, decodedCursor.offset, ...subArr) + }, decodedCursor.offset, limit, ...subArr) if (decodedCursor.offset === 0) { // get pins for the page and return those separately @@ -419,230 +420,11 @@ 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 = {} @@ -758,7 +540,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 AuthenticationError('item does not belong to you') + throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) } const data = { deletedAt: new Date() } @@ -813,9 +595,9 @@ export default { } }, upsertPoll: async (parent, { id, ...data }, { me, models }) => { - const { sub, forward, boost, title, text, options } = data + const { forward, sub, boost, title, text, options } = data if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } const optionCount = id @@ -832,14 +614,14 @@ export default { if (forward) { fwdUser = await models.user.findUnique({ where: { name: forward } }) if (!fwdUser) { - throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) + throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } }) } } if (id) { const old = await models.item.findUnique({ where: { id: Number(id) } }) if (Number(old.userId) !== Number(me?.id)) { - throw new AuthenticationError('item does not belong to you') + throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) } const [item] = await serialize(models, models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6, $7) AS "Item"`, @@ -860,13 +642,13 @@ export default { }, upsertJob: async (parent, { id, ...data }, { me, models }) => { if (!me) { - throw new AuthenticationError('you must be logged in to create job') + throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } }) } 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 UserInputError('not a valid sub', { argumentName: 'sub' }) + throw new GraphQLError('not a valid sub', { extensions: { code: 'BAD_INPUT' } }) } await ssValidate(jobSchema, data, models) @@ -876,7 +658,7 @@ export default { if (id) { const old = await models.item.findUnique({ where: { id: Number(id) } }) if (Number(old.userId) !== Number(me?.id)) { - throw new AuthenticationError('item does not belong to you') + throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) } ([item] = await serialize(models, models.$queryRaw( @@ -919,7 +701,7 @@ export default { }, pollVote: async (parent, { id }, { me, models }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } await serialize(models, @@ -931,7 +713,7 @@ export default { act: async (parent, { id, sats }, { me, models }) => { // need to make sure we are logged in if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } await ssValidate(amountSchema, { amount: sats }) @@ -942,7 +724,7 @@ export default { FROM "Item" WHERE id = $1 AND "userId" = $2`, Number(id), me.id) if (item) { - throw new UserInputError('cannot zap your self') + throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } }) } const [{ item_act: vote }] = await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'TIP', ${Number(sats)})`) @@ -964,7 +746,7 @@ export default { dontLikeThis: async (parent, { id }, { me, models }) => { // need to make sure we are logged in if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } // disallow self down votes @@ -973,7 +755,7 @@ export default { FROM "Item" WHERE id = $1 AND "userId" = $2`, Number(id), me.id) if (item) { - throw new UserInputError('cannot downvote your self') + throw new GraphQLError('cannot downvote your self', { extensions: { code: 'BAD_INPUT' } }) } await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST})`) @@ -992,11 +774,11 @@ export default { return item.subName === 'jobs' }, sub: async (item, args, { models }) => { - if (!item.subName) { + if (!item.subName && !item.root) { return null } - return await models.sub.findUnique({ where: { name: item.subName } }) + return await models.sub.findUnique({ where: { name: item.subName || item.root?.subName } }) }, position: async (item, args, { models }) => { if (!item.pinId) { @@ -1070,7 +852,8 @@ export default { if (item.comments) { return item.comments } - return comments(me, models, item.id, item.pinId ? 'recent' : 'hot') + + return comments(me, models, item.id, defaultCommentSort(item.pinId, item.bioId, item.createdAt)) }, wvotes: async (item) => { return item.weightedVotes - item.weightedDownVotes @@ -1226,28 +1009,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 AuthenticationError('item does not belong to you') + throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) } // 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 UserInputError('item can no longer be editted') + throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } }) } if (boost && boost < BOOST_MIN) { - throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) + throw new GraphQLError(`boost must be at least ${BOOST_MIN}`, { extensions: { code: 'BAD_INPUT' } }) } if (!old.parentId && title.length > MAX_TITLE_LENGTH) { - throw new UserInputError('title too long') + throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } }) } let fwdUser if (forward) { fwdUser = await models.user.findUnique({ where: { name: forward } }) if (!fwdUser) { - throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) + throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } }) } } @@ -1267,22 +1050,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 AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } if (boost && boost < BOOST_MIN) { - throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) + throw new GraphQLError(`boost must be at least ${BOOST_MIN}`, { extensions: { code: 'BAD_INPUT' } }) } if (!parentId && title.length > MAX_TITLE_LENGTH) { - throw new UserInputError('title too long') + throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } }) } let fwdUser if (forward) { fwdUser = await models.user.findUnique({ where: { name: forward } }) if (!fwdUser) { - throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) + throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } }) } } diff --git a/api/resolvers/lnurl.js b/api/resolvers/lnurl.js index 19ea5e6c..82072741 100644 --- a/api/resolvers/lnurl.js +++ b/api/resolvers/lnurl.js @@ -1,6 +1,6 @@ import { randomBytes } from 'crypto' import { bech32 } from 'bech32' -import { AuthenticationError } from 'apollo-server-micro' +import { GraphQLError } from 'graphql' 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 AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } return await models.lnWith.create({ data: { k1: k1(), userId: me.id } }) diff --git a/api/resolvers/message.js b/api/resolvers/message.js index ead01c84..cba48484 100644 --- a/api/resolvers/message.js +++ b/api/resolvers/message.js @@ -1,4 +1,4 @@ -import { UserInputError } from 'apollo-server-micro' +import { GraphQLError } from 'graphql' export default { Query: { @@ -11,7 +11,7 @@ export default { Mutation: { createMessage: async (parent, { text }, { me, models }) => { if (!text) { - throw new UserInputError('Must have text', { argumentName: 'text' }) + throw new GraphQLError('Must have text', { extensions: { code: 'BAD_INPUT' } }) } return await models.message.create({ diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 3a67e5f0..b53c9e03 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -1,4 +1,4 @@ -import { AuthenticationError, UserInputError } from 'apollo-server-micro' +import { GraphQLError } from 'graphql' 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 AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } 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 AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } await ssValidate(pushSubscriptionSchema, { endpoint, p256dh, auth }) @@ -250,14 +250,12 @@ export default { }, deletePushSubscription: async (parent, { endpoint }, { me, models }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } const subscription = await models.pushSubscription.findFirst({ where: { endpoint, userId: Number(me.id) } }) if (!subscription) { - throw new UserInputError('endpoint not found', { - argumentName: 'endpoint' - }) + throw new GraphQLError('endpoint not found', { extensions: { code: 'BAD_INPUT' } }) } await models.pushSubscription.delete({ where: { id: subscription.id } }) return subscription diff --git a/api/resolvers/referrals.js b/api/resolvers/referrals.js index ab5b8d66..2b8cf6bc 100644 --- a/api/resolvers/referrals.js +++ b/api/resolvers/referrals.js @@ -1,11 +1,11 @@ -import { AuthenticationError } from 'apollo-server-micro' +import { GraphQLError } from 'graphql' import { withClause, intervalClause, timeUnit } from './growth' export default { Query: { referrals: async (parent, { when }, { models, me }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } const [{ totalSats }] = await models.$queryRaw(` diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js index 255a0a72..c2da8779 100644 --- a/api/resolvers/rewards.js +++ b/api/resolvers/rewards.js @@ -1,4 +1,4 @@ -import { AuthenticationError } from 'apollo-server-micro' +import { GraphQLError } from 'graphql' 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 AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } await ssValidate(amountSchema, { amount: sats }) diff --git a/api/resolvers/search.js b/api/resolvers/search.js index fa73c79d..bb34bc99 100644 --- a/api/resolvers/search.js +++ b/api/resolvers/search.js @@ -79,7 +79,7 @@ export default { items } }, - search: async (parent, { q: query, cursor, sort, what, when }, { me, models, search }) => { + search: async (parent, { q: query, sub, cursor, sort, what, when }, { me, models, search }) => { const decodedCursor = decodeCursor(cursor) let sitems @@ -105,8 +105,7 @@ 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 sub = queryArr.find(word => word.startsWith('~')) - const exclude = [url, nym, sub] + const exclude = [url, nym] query = queryArr.filter(word => !exclude.includes(word)).join(' ') if (url) { @@ -118,7 +117,7 @@ export default { } if (sub) { - whatArr.push({ match: { 'sub.name': sub.slice(1).toLowerCase() } }) + whatArr.push({ match: { 'sub.name': sub } }) } const sortArr = [] @@ -247,7 +246,7 @@ export default { highlight: { fields: { title: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] }, - text: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] } + text: { number_of_fragments: 5, order: 'score', pre_tags: [':high['], post_tags: [']'] } } } } @@ -266,7 +265,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[0]) || item.text + item.searchText = (e.highlight?.text && e.highlight.text.join(' `...` ')) || undefined return item }) diff --git a/api/resolvers/serial.js b/api/resolvers/serial.js index 7efdeb7d..d6a94e47 100644 --- a/api/resolvers/serial.js +++ b/api/resolvers/serial.js @@ -1,4 +1,4 @@ -const { UserInputError } = require('apollo-server-micro') +const { GraphQLError } = require('graphql') 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 UserInputError('insufficient funds')) + bail(new GraphQLError('insufficient funds', { extensions: { code: 'BAD_INPUT' } })) } 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 0ec79f84..4462cc60 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -1,6 +1,8 @@ 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 def8c906..b8bd1e93 100644 --- a/api/resolvers/upload.js +++ b/api/resolvers/upload.js @@ -1,4 +1,4 @@ -import { AuthenticationError, UserInputError } from 'apollo-server-micro' +import { GraphQLError } from 'graphql' 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 AuthenticationError('you must be logged in to get a signed url') + throw new GraphQLError('you must be logged in to get a signed url', { extensions: { code: 'FORBIDDEN' } }) } if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) { - throw new UserInputError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`) + throw new GraphQLError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`, { extensions: { code: 'BAD_INPUT' } }) } if (size > UPLOAD_SIZE_MAX) { - throw new UserInputError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`) + throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`, { extensions: { code: 'BAD_INPUT' } }) } if (width * height > IMAGE_PIXELS_MAX) { - throw new UserInputError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`) + throw new GraphQLError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`, { extensions: { code: 'BAD_INPUT' } }) } // create upload record diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 245d31db..661ea79d 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -1,9 +1,10 @@ -import { AuthenticationError, UserInputError } from 'apollo-server-errors' +import { GraphQLError } from 'graphql' 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 ' @@ -53,13 +54,13 @@ export function viewWithin (table, within) { export function withinDate (within) { switch (within) { case 'day': - return new Date(new Date().setDate(new Date().getDate() - 1)) + return dayPivot(new Date(), -1) case 'week': - return new Date(new Date().setDate(new Date().getDate() - 7)) + return dayPivot(new Date(), -7) case 'month': - return new Date(new Date().setDate(new Date().getDate() - 30)) + return dayPivot(new Date(), -30) case 'year': - return new Date(new Date().setDate(new Date().getDate() - 365)) + return dayPivot(new Date(), -365) default: return new Date(0) } @@ -97,7 +98,7 @@ export default { }, settings: async (parent, args, { models, me }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } return await models.user.findUnique({ where: { id: me.id } }) @@ -109,7 +110,7 @@ export default { await models.user.findMany(), nameAvailable: async (parent, { name }, { models, me }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } const user = await models.user.findUnique({ where: { id: me.id } }) @@ -120,7 +121,7 @@ export default { const decodedCursor = decodeCursor(cursor) const users = await models.$queryRaw(` SELECT users.*, floor(sum(msats_spent)/1000) as spent, - sum(posts) as nitems, sum(comments) as ncomments, sum(referrals) as referrals, + sum(posts) as nposts, 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 @@ -134,15 +135,15 @@ export default { users } }, - topUsers: async (parent, { cursor, when, sort }, { models, me }) => { + topUsers: async (parent, { cursor, when, by }, { models, me }) => { const decodedCursor = decodeCursor(cursor) let users if (when !== 'day') { let column - switch (sort) { + switch (by) { case 'spent': column = 'spent'; break - case 'posts': column = 'nitems'; break + case 'posts': column = 'nposts'; break case 'comments': column = 'ncomments'; break case 'referrals': column = 'referrals'; break default: column = 'stacked'; break @@ -151,7 +152,7 @@ export default { users = await models.$queryRaw(` WITH u AS ( SELECT users.*, floor(sum(msats_spent)/1000) as spent, - sum(posts) as nitems, sum(comments) as ncomments, sum(referrals) as referrals, + sum(posts) as nposts, 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 @@ -170,7 +171,7 @@ export default { } } - if (sort === 'spent') { + if (by === 'spent') { users = await models.$queryRaw(` SELECT users.*, sum(sats_spent) as spent FROM @@ -190,19 +191,19 @@ export default { ORDER BY spent DESC NULLS LAST, users.created_at DESC OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) - } else if (sort === 'posts') { + } else if (by === 'posts') { users = await models.$queryRaw(` - SELECT users.*, count(*) as nitems + SELECT users.*, count(*) as nposts 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 nitems DESC NULLS LAST, users.created_at DESC + ORDER BY nposts DESC NULLS LAST, users.created_at DESC OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) - } else if (sort === 'comments') { + } else if (by === 'comments') { users = await models.$queryRaw(` SELECT users.*, count(*) as ncomments FROM users @@ -214,7 +215,7 @@ export default { ORDER BY ncomments DESC NULLS LAST, users.created_at DESC OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) - } else if (sort === 'referrals') { + } else if (by === 'referrals') { users = await models.$queryRaw(` SELECT users.*, count(*) as referrals FROM users @@ -427,23 +428,24 @@ export default { Mutation: { setName: async (parent, data, { me, models }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } 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 UserInputError('name taken') + throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } }) } throw error } }, setSettings: async (parent, { nostrRelays, ...data }, { me, models }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } await ssValidate(settingsSchema, { nostrRelays, ...data }) @@ -469,7 +471,7 @@ export default { }, setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } await models.user.update({ where: { id: me.id }, data: { upvotePopover, tipPopover } }) @@ -478,7 +480,7 @@ export default { }, setPhoto: async (parent, { photoId }, { me, models }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } await models.user.update({ @@ -490,7 +492,7 @@ export default { }, upsertBio: async (parent, { bio }, { me, models }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } await ssValidate(bioSchema, { bio }) @@ -510,7 +512,7 @@ export default { }, unlinkAuth: async (parent, { authType }, { models, me }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } let user @@ -518,7 +520,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 UserInputError('no such account') + throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } }) } await models.account.delete({ where: { id: account.id } }) } else if (authType === 'lightning') { @@ -528,14 +530,14 @@ export default { } else if (authType === 'email') { user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } }) } else { - throw new UserInputError('no such account') + throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } }) } return await authMethods(user, undefined, { models, me }) }, linkUnverifiedEmail: async (parent, { email }, { models, me }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } await ssValidate(emailSchema, { email }) @@ -547,7 +549,7 @@ export default { }) } catch (error) { if (error.code === 'P2002') { - throw new UserInputError('email taken') + throw new GraphQLError('email taken', { extensions: { code: 'BAD_INPUT' } }) } throw error } @@ -581,6 +583,20 @@ 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 d4f3ebe6..d72c9ccf 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,5 +1,5 @@ import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service' -import { UserInputError, AuthenticationError } from 'apollo-server-micro' +import { GraphQLError } from 'graphql' 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 AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } 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 AuthenticationError('not ur invoice') + throw new GraphQLError('not ur invoice', { extensions: { code: 'FORBIDDEN' } }) } return inv @@ -34,7 +34,7 @@ export default { invoice: getInvoice, withdrawl: async (parent, { id }, { me, models, lnd }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } const wdrwl = await models.withdrawl.findUnique({ @@ -47,7 +47,7 @@ export default { }) if (wdrwl.user.id !== me.id) { - throw new AuthenticationError('not ur withdrawal') + throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } }) } return wdrwl @@ -58,7 +58,7 @@ export default { walletHistory: async (parent, { cursor, inc }, { me, models, lnd }) => { const decodedCursor = decodeCursor(cursor) if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } const include = new Set(inc?.split(',')) @@ -191,7 +191,7 @@ export default { Mutation: { createInvoice: async (parent, { amount }, { me, models, lnd }) => { if (!me) { - throw new AuthenticationError('you must be logged in') + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } await ssValidate(amountSchema, { amount }) @@ -239,9 +239,7 @@ export default { const milliamount = amount * 1000 // check that amount is within min and max sendable if (milliamount < res1.minSendable || milliamount > res1.maxSendable) { - throw new UserInputError( - `amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`, - { argumentName: 'amount' }) + throw new GraphQLError(`amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`, { extensions: { code: 'BAD_INPUT' } }) } const callback = new URL(res1.callback) @@ -311,11 +309,11 @@ async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd decoded = await decodePaymentRequest({ lnd, request: invoice }) } catch (error) { console.log(error) - throw new UserInputError('could not decode invoice') + throw new GraphQLError('could not decode invoice', { extensions: { code: 'BAD_INPUT' } }) } if (!decoded.mtokens || BigInt(decoded.mtokens) <= 0) { - throw new UserInputError('your invoice must specify an amount') + throw new GraphQLError('your invoice must specify an amount', { extensions: { code: 'BAD_INPUT' } }) } const msatsFee = Number(maxFee) * 1000 diff --git a/api/ssrApollo.js b/api/ssrApollo.js index bd70d679..c9fac53b 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -31,16 +31,38 @@ export default async function getSSRApolloClient (req, me = null) { slashtags } }), - cache: new InMemoryCache() + 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 + } + } }) - await client.clearStore() return client } -export function getGetServerSideProps (query, variables = null, notFoundFunc, requireVar) { +export function getGetServerSideProps (queryOrFunc, variablesOrFunc = 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({ @@ -52,20 +74,6 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re 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 @@ -91,7 +99,7 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re throw err } - if (error || !data || (notFoundFunc && notFoundFunc(data))) { + if (error || !data || (notFoundFunc && notFoundFunc(data, vars))) { return { notFound: true } @@ -110,7 +118,7 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re ...props, me, price, - data + ssrData: data } } } diff --git a/api/typeDefs/admin.js b/api/typeDefs/admin.js index 704219bc..8d82f172 100644 --- a/api/typeDefs/admin.js +++ b/api/typeDefs/admin.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' export default gql` extend type Query { diff --git a/api/typeDefs/growth.js b/api/typeDefs/growth.js index 2b41fa8f..aa53b9df 100644 --- a/api/typeDefs/growth.js +++ b/api/typeDefs/growth.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' export default gql` type NameValue { diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index 0745be29..3c659be7 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' import user from './user' import message from './message' diff --git a/api/typeDefs/invite.js b/api/typeDefs/invite.js index 8d377f84..217d15c7 100644 --- a/api/typeDefs/invite.js +++ b/api/typeDefs/invite.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' export default gql` extend type Query { diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index d31c75e8..7b44c4f0 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -1,25 +1,16 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' export default gql` extend type Query { - 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 + items(sub: String, sort: String, type: String, cursor: String, name: String, when: String, by: String, limit: Int): 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 - allItems(cursor: String): Items - getBountiesByUserName(name: String!, cursor: String, , limit: Int): Items - search(q: String, cursor: String, what: String, sort: String, when: String): Items + search(q: String, sub: 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 9ef3d9d9..ce1b1dac 100644 --- a/api/typeDefs/lnurl.js +++ b/api/typeDefs/lnurl.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' export default gql` extend type Query { diff --git a/api/typeDefs/message.js b/api/typeDefs/message.js index 00778f22..a08611b1 100644 --- a/api/typeDefs/message.js +++ b/api/typeDefs/message.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' export default gql` extend type Query { diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 2045daef..80831d9e 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' export default gql` extend type Query { @@ -11,33 +11,39 @@ 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! @@ -45,24 +51,27 @@ 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 4bfdc295..5ffb8e36 100644 --- a/api/typeDefs/price.js +++ b/api/typeDefs/price.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' export default gql` extend type Query { diff --git a/api/typeDefs/referrals.js b/api/typeDefs/referrals.js index 5309f37d..ab636c9d 100644 --- a/api/typeDefs/referrals.js +++ b/api/typeDefs/referrals.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' export default gql` extend type Query { diff --git a/api/typeDefs/rewards.js b/api/typeDefs/rewards.js index e9776c20..85ca1ba4 100644 --- a/api/typeDefs/rewards.js +++ b/api/typeDefs/rewards.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' export default gql` extend type Query { diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index 4a96855c..e279b6fb 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -1,8 +1,8 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' 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 9ea1621b..f462458e 100644 --- a/api/typeDefs/upload.js +++ b/api/typeDefs/upload.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' export default gql` scalar JSONObject diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 327ea9f9..04a0ca43 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' 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, sort: String): Users + topUsers(cursor: String, when: String, by: 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!): Boolean + setName(name: String!): String 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,6 +45,7 @@ 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 ff06a926..54ad278f 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -1,4 +1,4 @@ -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' export default gql` extend type Query { diff --git a/components/avatar.js b/components/avatar.js index d9fd394a..7dec407b 100644 --- a/components/avatar.js +++ b/components/avatar.js @@ -1,74 +1,72 @@ import { useRef, useState } from 'react' import AvatarEditor from 'react-avatar-editor' -import { Button, Modal, Form as BootstrapForm } from 'react-bootstrap' +import { Button, 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 ( - <> - 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) - }} - /> - + +
+ {uploading + ? + : } +
} + onError={e => { + console.log(e) + setUploading(false) + }} + onSelect={(file, upload) => { + showModal(onClose => ) + }} + onSuccess={async key => { + onSuccess && onSuccess(key) + setUploading(false) + }} + onStarted={() => { + setUploading(true) + }} + /> ) } diff --git a/components/bookmark.js b/components/bookmark.js index dc689ccc..55e8d024 100644 --- a/components/bookmark.js +++ b/components/bookmark.js @@ -1,5 +1,5 @@ import { useMutation } from '@apollo/client' -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' 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 bdc35f63..93b18852 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -1,7 +1,6 @@ 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' @@ -100,7 +99,6 @@ export function BountyForm ({ } name='text' - as={TextareaAutosize} minRows={6} hint={ editThreshold diff --git a/components/comment-edit.js b/components/comment-edit.js index 037b113d..f9ab8156 100644 --- a/components/comment-edit.js +++ b/components/comment-edit.js @@ -1,7 +1,6 @@ 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' @@ -47,7 +46,6 @@ 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,32 +54,42 @@ const truncateString = (string = '', maxLength = 140) => ? `${string.substring(0, maxLength)} […]` : string -export function CommentFlat ({ item, ...props }) { +export function CommentFlat ({ item, rank, ...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 ( -
{ - 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}`) - } - }} - > - - - -
+ <> + {rank + ? ( +
+ {rank} +
) + :
} +
{ + if (ignoreClick(e)) return + router.push(href, as) + }} + > + + + +
+ ) } @@ -183,24 +193,26 @@ export default function Comment ({ )}
- {bottomedOut - ? - : ( -
- {!noReply && - - {root.bounty && !bountyPaid && } - } - {children} -
- {item.comments && !noComments - ? item.comments.map((item) => ( - - )) - : null} + {collapse !== 'yep' && ( + bottomedOut + ? + : ( +
+ {!noReply && + + {root.bounty && !bountyPaid && } + } + {children} +
+ {item.comments && !noComments + ? item.comments.map((item) => ( + + )) + : null} +
-
- )} + ) + )}
) } @@ -208,8 +220,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 deleted file mode 100644 index bfbbe6f6..00000000 --- a/components/comments-flat.js +++ /dev/null @@ -1,42 +0,0 @@ -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 df531886..eb05d250 100644 --- a/components/comments.js +++ b/components/comments.js @@ -1,14 +1,15 @@ import { gql, useApolloClient, useLazyQuery } from '@apollo/client' -import { useEffect, useState } from 'react' +import { 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, commentSats }) { - const [sort, setSort] = useState(pinned ? 'recent' : 'hot') +export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { + const [sort, setSort] = useState(defaultCommentSort(pinned, bio, parentCreatedAt)) const getHandleClick = sort => { return () => { @@ -60,19 +61,12 @@ export function CommentsHeader ({ handleSort, pinned, commentSats }) { ) } -export default function Comments ({ parentId, pinned, commentSats, comments, ...props }) { +export default function Comments ({ parentId, pinned, bio, parentCreatedAt, 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: 'network-only', + fetchPolicy: 'cache-first', onCompleted: data => { client.writeFragment({ id: `Item:${parentId}`, @@ -97,7 +91,8 @@ export default function Comments ({ parentId, pinned, commentSats, comments, ... <> {comments.length ? { + commentSats={commentSats} parentCreatedAt={parentCreatedAt} + pinned={pinned} bio={bio} handleSort={sort => { setLoading(true) getComments({ variables: { id: parentId, sort } }) }} diff --git a/components/countdown.js b/components/countdown.js index cc7ef4da..31b77e1f 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 new file mode 100644 index 00000000..fe6e97f0 --- /dev/null +++ b/components/dark-mode.js @@ -0,0 +1,17 @@ +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 bdb5e123..4b22f2b3 100644 --- a/components/delete.js +++ b/components/delete.js @@ -1,5 +1,5 @@ import { useMutation } from '@apollo/client' -import { gql } from 'apollo-server-micro' +import { gql } from 'graphql-tag' 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 e58c9559..ce13dae7 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -1,7 +1,6 @@ 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' @@ -36,16 +35,14 @@ 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 || [] @@ -96,7 +93,6 @@ 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 d411f605..f5a637ac 100644 --- a/components/error-boundary.js +++ b/components/error-boundary.js @@ -1,5 +1,5 @@ import { Component } from 'react' -import LayoutStatic from './layout-static' +import { StaticLayout } from './layout' 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 6f571321..a889fbce 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 }) + const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' }) 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 b12fdc12..ea81b920 100644 --- a/components/footer-rewards.js +++ b/components/footer-rewards.js @@ -10,14 +10,12 @@ const REWARDS = gql` }` export default function Rewards () { - const { data } = useQuery(REWARDS, { pollInterval: 60000, fetchPolicy: 'cache-and-network' }) + const { data } = useQuery(REWARDS, { pollInterval: 60000, nextFetchPolicy: 'cache-and-network' }) const total = data?.expectedRewards?.total return ( - - - {total ? : 'rewards'} - + + {total ? : 'rewards'} ) } diff --git a/components/footer.js b/components/footer.js index fedb35a6..32cb3dfd 100644 --- a/components/footer.js +++ b/components/footer.js @@ -1,12 +1,9 @@ -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' @@ -14,70 +11,7 @@ import Bolt from '../svgs/bolt.svg' import Amboss from '../svgs/amboss.svg' import { useEffect, useState } from 'react' import Rewards from './footer-rewards' - -// 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) - }) -} +import useDarkMode from './dark-mode' const RssPopover = ( @@ -179,33 +113,19 @@ const AnalyticsPopover = ( visitors \ - - - stackers - + + stackers ) -export default function Footer ({ noLinks }) { - const query = gql` - { - connectAddress - } - ` - const { data } = useQuery(query, { fetchPolicy: 'cache-first' }) +export default function Footer ({ links = true }) { + const [darkMode, darkModeToggle] = useDarkMode() - 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') }, []) @@ -219,7 +139,7 @@ export default function Footer ({ noLinks }) { } } - const DarkModeIcon = darkMode.value ? Sun : Moon + const DarkModeIcon = darkMode ? Sun : Moon const LnIcon = lightning === 'yes' ? No : Bolt const version = process.env.NEXT_PUBLIC_COMMIT_HASH @@ -227,13 +147,12 @@ export default function Footer ({ noLinks }) { return (