Revert "Revert "shield your eyes; massive, squashed refactor; nextjs/react/react-dom/apollo upgrades""

This reverts commit 18910fa2ed.
This commit is contained in:
keyan 2023-07-23 10:08:43 -05:00
parent 441ec62476
commit 59f7b6ff26
192 changed files with 6886 additions and 9114 deletions

1
.npmrc
View File

@ -1,3 +1,2 @@
unsafe-perm=true
legacy-peer-deps=true

View File

@ -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({

View File

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

View File

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

View File

@ -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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { gql } from 'apollo-server-micro'
import { gql } from 'graphql-tag'
export default gql`
extend type Query {

View File

@ -1,4 +1,4 @@
import { gql } from 'apollo-server-micro'
import { gql } from 'graphql-tag'
export default gql`
type NameValue {

View File

@ -1,4 +1,4 @@
import { gql } from 'apollo-server-micro'
import { gql } from 'graphql-tag'
import user from './user'
import message from './message'

View File

@ -1,4 +1,4 @@
import { gql } from 'apollo-server-micro'
import { gql } from 'graphql-tag'
export default gql`
extend type Query {

View File

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

View File

@ -1,4 +1,4 @@
import { gql } from 'apollo-server-micro'
import { gql } from 'graphql-tag'
export default gql`
extend type Query {

View File

@ -1,4 +1,4 @@
import { gql } from 'apollo-server-micro'
import { gql } from 'graphql-tag'
export default gql`
extend type Query {

View File

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

View File

@ -1,4 +1,4 @@
import { gql } from 'apollo-server-micro'
import { gql } from 'graphql-tag'
export default gql`
extend type Query {

View File

@ -1,4 +1,4 @@
import { gql } from 'apollo-server-micro'
import { gql } from 'graphql-tag'
export default gql`
extend type Query {

View File

@ -1,4 +1,4 @@
import { gql } from 'apollo-server-micro'
import { gql } from 'graphql-tag'
export default gql`
extend type Query {

View File

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

View File

@ -1,4 +1,4 @@
import { gql } from 'apollo-server-micro'
import { gql } from 'graphql-tag'
export default gql`
scalar JSONObject

View File

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

View File

@ -1,4 +1,4 @@
import { gql } from 'apollo-server-micro'
import { gql } from 'graphql-tag'
export default gql`
extend type Query {

View File

@ -1,27 +1,23 @@
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 (
<>
<Modal
show={!!editProps}
onHide={() => setEditProps(null)}
>
<div className='modal-close' onClick={() => setEditProps(null)}>X</div>
<Modal.Body className='text-right mt-1 p-4'>
<div className='text-right mt-1 p-4'>
<AvatarEditor
ref={ref} width={200} height={200}
image={editProps?.file}
image={file}
scale={scale}
style={{
width: '100%',
@ -38,15 +34,18 @@ export default function Avatar ({ onSuccess }) {
<Button onClick={() => {
ref.current.getImageScaledToCanvas().toBlob(blob => {
if (blob) {
editProps.upload(blob)
setEditProps(null)
upload(blob)
onClose()
}
}, 'image/jpeg')
}}
>save
</Button>
</Modal.Body>
</Modal>
</div>
)
}
return (
<Upload
as={({ onClick }) =>
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
@ -59,7 +58,7 @@ export default function Avatar ({ onSuccess }) {
setUploading(false)
}}
onSelect={(file, upload) => {
setEditProps({ file, upload })
showModal(onClose => <Body onClose={onClose} file={file} upload={upload} />)
}}
onSuccess={async key => {
onSuccess && onSuccess(key)
@ -69,6 +68,5 @@ export default function Avatar ({ onSuccess }) {
setUploading(true)
}}
/>
</>
)
}

View File

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

View File

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

View File

@ -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
>
<MarkdownInput
name='text'
as={TextareaAutosize}
minRows={6}
autoFocus
required

View File

@ -3,7 +3,7 @@ import styles from './comment.module.css'
import Text from './text'
import Link from 'next/link'
import Reply, { ReplyOnAnotherPage } from './reply'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import UpVote from './upvote'
import Eye from '../svgs/eye-fill.svg'
import EyeClose from '../svgs/eye-close-line.svg'
@ -28,8 +28,8 @@ function Parent ({ item, rootText }) {
const ParentFrag = () => (
<>
<span> \ </span>
<Link href={`/items/${item.parentId}`} passHref>
<a className='text-reset'>parent</a>
<Link href={`/items/${item.parentId}`} className='text-reset'>
parent
</Link>
</>
)
@ -38,12 +38,12 @@ function Parent ({ item, rootText }) {
<>
{Number(root.id) !== Number(item.parentId) && <ParentFrag />}
<span> \ </span>
<Link href={`/items/${root.id}`} passHref>
<a className='text-reset'>{rootText || 'on:'} {root?.title}</a>
<Link href={`/items/${root.id}`} className='text-reset'>
{rootText || 'on:'} {root?.title}
</Link>
{root.subName &&
<Link href={`/~${root.subName}`}>
<a>{' '}<Badge className={itemStyles.newComment} variant={null}>{root.subName}</Badge></a>
{' '}<Badge className={itemStyles.newComment} variant={null}>{root.subName}</Badge>
</Link>}
</>
)
@ -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 (
<>
{rank
? (
<div className={`${itemStyles.rank} pt-2 align-self-start`}>
{rank}
</div>)
: <div />}
<div
className='clickToContext py-2'
onClick={e => {
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}`)
}
if (ignoreClick(e)) return
router.push(href, as)
}}
>
<RootProvider root={item.root}>
<Comment item={item} {...props} />
</RootProvider>
</div>
</>
)
}
@ -183,7 +193,8 @@ export default function Comment ({
)}
</div>
</div>
{bottomedOut
{collapse !== 'yep' && (
bottomedOut
? <DepthLimit item={item} />
: (
<div className={`${styles.children}`}>
@ -200,6 +211,7 @@ export default function Comment ({
: null}
</div>
</div>
)
)}
</div>
)
@ -208,8 +220,8 @@ export default function Comment ({
function DepthLimit ({ item }) {
if (item.ncomments > 0) {
return (
<Link href={`/items/${item.id}`} passHref>
<a className='d-block p-3 font-weight-bold text-muted w-100 text-center'>view replies</a>
<Link href={`/items/${item.id}`} className='d-block p-3 font-weight-bold text-muted w-100 text-center'>
view replies
</Link>
)
}

View File

@ -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 <CommentsFlatSkeleton />
}
if (data) {
if (destructureData) {
({ comments, cursor } = destructureData(data))
} else {
({ moreFlatComments: { comments, cursor } } = data)
}
}
return (
<>
{comments.map(item =>
<CommentFlat key={item.id} item={item} {...props} />
)}
<MoreFooter cursor={cursor} fetchMore={fetchMore} Skeleton={CommentsFlatSkeleton} />
</>
)
}
function CommentsFlatSkeleton () {
const comments = new Array(21).fill(null)
return (
<div>{comments.map((_, i) => (
<CommentSkeleton key={i} skeletonChildren={0} />
))}
</div>
)
}

View File

@ -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
? <CommentsHeader
commentSats={commentSats} pinned={pinned} handleSort={sort => {
commentSats={commentSats} parentCreatedAt={parentCreatedAt}
pinned={pinned} bio={bio} handleSort={sort => {
setLoading(true)
getComments({ variables: { id: parentId, sort } })
}}

View File

@ -5,7 +5,7 @@ export default function SimpleCountdown ({ className, onComplete, date }) {
<span className={className}>
<Countdown
date={date}
renderer={props => <span className='text-monospace'> {props.formatted.minutes}:{props.formatted.seconds}</span>}
renderer={props => <span className='text-monospace' suppressHydrationWarning> {props.formatted.minutes}:{props.formatted.seconds}</span>}
onComplete={onComplete}
/>
</span>

17
components/dark-mode.js Normal file
View File

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

View File

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

View File

@ -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'
@ -43,9 +42,7 @@ export function DiscussionForm ({
...ItemFields
}
}
}`, {
fetchPolicy: 'network-only'
})
}`)
const related = relatedData?.related?.items || []
@ -96,7 +93,6 @@ export function DiscussionForm ({
topLevel
label={<>{textLabel} <small className='text-muted ml-2'>optional</small></>}
name='text'
as={TextareaAutosize}
minRows={6}
hint={editThreshold
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>

View File

@ -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 (
<LayoutStatic>
<Image width='500' height='375' src='/floating.gif' fluid />
<StaticLayout>
<Image width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.gif`} fluid />
<h1 className={styles.fourZeroFour} style={{ fontSize: '48px' }}>something went wrong</h1>
</LayoutStatic>
</StaticLayout>
)
}

View File

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

View File

@ -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 (
<Link href='/rewards' passHref>
<a className='nav-link p-0 p-0 d-inline-flex'>
<Link href='/rewards' className='nav-link p-0 p-0 d-inline-flex'>
{total ? <span><RewardLine total={total} /></span> : 'rewards'}
</a>
</Link>
)
}

View File

@ -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 = (
<Popover>
@ -179,33 +113,19 @@ const AnalyticsPopover = (
visitors
</a>
<span className='mx-2 text-muted'> \ </span>
<Link href='/stackers/day' passHref>
<a className='nav-link p-0 d-inline-flex'>
<Link href='/stackers/day' className='nav-link p-0 d-inline-flex'>
stackers
</a>
</Link>
</Popover.Content>
</Popover>
)
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 (
<footer>
<Container className='mb-3 mt-4'>
{!noLinks &&
{links &&
<>
{mounted &&
<div className='mb-1'>
<DarkModeIcon onClick={() => darkMode.toggle()} width={20} height={20} className='fill-grey theme' />
<LnIcon onClick={toggleLightning} width={20} height={20} className='ml-2 fill-grey theme' />
</div>}
<DarkModeIcon onClick={darkModeToggle} width={20} height={20} className='fill-grey theme' suppressHydrationWarning />
<LnIcon onClick={toggleLightning} width={20} height={20} className='ml-2 fill-grey theme' suppressHydrationWarning />
</div>
<div className='mb-0' style={{ fontWeight: 500 }}>
<Rewards />
</div>
@ -263,38 +182,28 @@ export default function Footer ({ noLinks }) {
</OverlayTrigger>
</div>
<div className='mb-2' style={{ fontWeight: 500 }}>
<Link href='/faq' passHref>
<a className='nav-link p-0 p-0 d-inline-flex'>
<Link href='/faq' className='nav-link p-0 p-0 d-inline-flex'>
faq
</a>
</Link>
<span className='mx-2 text-muted'> \ </span>
<Link href='/guide' passHref>
<a className='nav-link p-0 p-0 d-inline-flex'>
<Link href='/guide' className='nav-link p-0 p-0 d-inline-flex'>
guide
</a>
</Link>
<span className='mx-2 text-muted'> \ </span>
<Link href='/story' passHref>
<a className='nav-link p-0 p-0 d-inline-flex'>
<Link href='/story' className='nav-link p-0 p-0 d-inline-flex'>
story
</a>
</Link>
<span className='mx-2 text-muted'> \ </span>
<Link href='/changes' passHref>
<a className='nav-link p-0 p-0 d-inline-flex'>
<Link href='/changes' className='nav-link p-0 p-0 d-inline-flex'>
changes
</a>
</Link>
<span className='mx-2 text-muted'> \ </span>
<Link href='/privacy' passHref>
<a className='nav-link p-0 p-0 d-inline-flex'>
<Link href='/privacy' className='nav-link p-0 p-0 d-inline-flex'>
privacy
</a>
</Link>
</div>
</>}
{data &&
{process.env.NEXT_PUBLIC_LND_CONNECT_ADDRESS &&
<div
className={`text-small mx-auto mb-2 ${styles.connect}`}
>
@ -304,7 +213,7 @@ export default function Footer ({ noLinks }) {
groupClassName='mb-0 w-100'
readOnly
noForm
placeholder={data.connectAddress}
placeholder={process.env.NEXT_PUBLIC_LND_CONNECT_ADDRESS}
/>
<a
href='https://amboss.space/node/03cc1d0932bb99b0697f5b5e5961b83ab7fd66f1efc4c9f5c7bad66c1bcbe78f02'
@ -320,14 +229,14 @@ export default function Footer ({ noLinks }) {
made in Austin<Texas className='ml-1' width={20} height={20} />
<span className='ml-1'>by</span>
<span>
<Link href='/k00b' passHref>
<a className='ml-1'>@k00b</a>
<Link href='/k00b' className='ml-1'>
@k00b
</Link>
<Link href='/kr' passHref>
<a className='ml-1'>@kr</a>
<Link href='/kr' className='ml-1'>
@kr
</Link>
<Link href='/ekzyis' passHref>
<a className='ml-1'>@ekzyis</a>
<Link href='/ekzyis' className='ml-1'>
@ekzyis
</Link>
</span>
</small>

View File

@ -15,6 +15,7 @@ import { mdHas } from '../lib/md'
import CloseIcon from '../svgs/close-line.svg'
import { useLazyQuery } from '@apollo/client'
import { USER_SEARCH } from '../fragments/users'
import TextareaAutosize from 'react-textarea-autosize'
export function SubmitButton ({
children, variant, value, onClick, disabled, ...props
@ -82,6 +83,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
innerRef = innerRef || useRef(null)
props.as ||= TextareaAutosize
props.rows ||= props.minRows || 6
useEffect(() => {
!meta.value && setTab('write')
}, [meta.value])
@ -111,7 +115,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
<Markdown width={18} height={18} />
</a>
</Nav>
<div className={tab !== 'write' ? 'd-none' : ''}>
{tab === 'write'
? (
<div>
<InputInner
{...props} onChange={(formik, e) => {
if (onChange) onChange(formik, e)
@ -147,12 +153,15 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
if (onKeyDown) onKeyDown(e)
}}
/>
</div>
<div className={tab !== 'preview' ? 'd-none' : 'form-group'}>
</div>)
: (
<div className='form-group'>
<div className={`${styles.text} form-control`}>
{tab === 'preview' && <Text topLevel={topLevel} noFragments onlyImgProxy={false}>{meta.value}</Text>}
<Text topLevel={topLevel} noFragments onlyImgProxy={false}>{meta.value}</Text>
</div>
</div>
)}
</div>
</FormGroup>
)
@ -300,7 +309,6 @@ function InputInner ({
export function InputUserSuggest ({ label, groupClassName, ...props }) {
const [getSuggestions] = useLazyQuery(USER_SEARCH, {
fetchPolicy: 'network-only',
onCompleted: data => {
setSuggestions({ array: data.searchUsers, index: 0 })
}
@ -476,10 +484,17 @@ export function Form ({
)
}
export function Select ({ label, items, groupClassName, onChange, noForm, ...props }) {
const [field, meta] = noForm ? [{}, {}] : useField(props)
export function Select ({ label, items, groupClassName, onChange, noForm, overrideValue, ...props }) {
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
const formik = noForm ? null : useFormikContext()
const invalid = meta.touched && meta.error
useEffect(() => {
if (overrideValue) {
helpers.setValue(overrideValue)
}
}, [overrideValue])
return (
<FormGroup label={label} className={groupClassName}>
<BootstrapForm.Control

View File

@ -8,11 +8,11 @@ import Price from './price'
import { useMe } from './me'
import Head from 'next/head'
import { signOut } from 'next-auth/client'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { randInRange } from '../lib/rand'
import { abbrNum } from '../lib/format'
import NoteIcon from '../svgs/notification-4-fill.svg'
import { useQuery, gql } from '@apollo/client'
import { useQuery } from '@apollo/client'
import LightningIcon from '../svgs/bolt.svg'
import CowboyHat from './cowboy-hat'
import { Form, Select } from './form'
@ -20,10 +20,10 @@ import SearchIcon from '../svgs/search-line.svg'
import BackArrow from '../svgs/arrow-left-line.svg'
import { SUBS } from '../lib/constants'
import { useLightning } from './lightning'
import { HAS_NOTIFICATIONS } from '../fragments/notifications'
function WalletSummary ({ me }) {
if (!me) return null
return `${abbrNum(me.sats)}`
}
@ -42,75 +42,48 @@ function Back () {
return null
}
export default function Header ({ sub }) {
const router = useRouter()
const [fired, setFired] = useState()
const [topNavKey, setTopNavKey] = useState('')
const [dropNavKey, setDropNavKey] = useState('')
const [prefix, setPrefix] = useState('')
const [path, setPath] = useState('')
const me = useMe()
useEffect(() => {
// there's always at least 2 on the split, e.g. '/' yields ['','']
const path = router.asPath.split('?')[0]
setPrefix(sub ? `/~${sub}` : '')
setTopNavKey(path.split('/')[sub ? 2 : 1] ?? '')
setDropNavKey(path.split('/').slice(sub ? 2 : 1).join('/'))
setPath(path)
}, [sub, router.asPath])
// const { data: subLatestPost } = useQuery(gql`
// query subLatestPost($name: ID!) {
// subLatestPost(name: $name)
// }
// `, { variables: { name: 'jobs' }, pollInterval: 600000, fetchPolicy: 'network-only' })
const { data: hasNewNotes } = useQuery(gql`
{
hasNewNotes
}
`, {
function NotificationBell () {
const { data } = useQuery(HAS_NOTIFICATIONS, {
pollInterval: 30000,
fetchPolicy: 'cache-and-network'
nextFetchPolicy: 'cache-and-network'
})
// const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
// useEffect(() => {
// if (me) {
// setLastCheckedJobs(me.lastCheckedJobs)
// } else {
// if (sub === 'jobs') {
// localStorage.setItem('lastCheckedJobs', new Date().getTime())
// }
// setLastCheckedJobs(localStorage.getItem('lastCheckedJobs'))
// }
// }, [sub])
const Corner = () => {
if (me) {
return (
<div className='d-flex align-items-center ml-auto'>
<>
<Head>
<link rel='shortcut icon' href={hasNewNotes?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
<link rel='shortcut icon' href={data?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
</Head>
<Link href='/notifications' passHref>
<Link href='/notifications' passHref legacyBehavior>
<Nav.Link eventKey='notifications' className='pl-0 position-relative'>
<NoteIcon height={22} width={22} className='theme' />
{hasNewNotes?.hasNewNotes &&
{data?.hasNewNotes &&
<span className={styles.notification}>
<span className='invisible'>{' '}</span>
</span>}
</Nav.Link>
</Link>
</>
)
}
function StackerCorner ({ dropNavKey }) {
const me = useMe()
return (
<div className='d-flex align-items-center ml-auto'>
<NotificationBell />
<div className='position-relative'>
<NavDropdown
className={styles.dropdown} title={
<Nav.Link eventKey={me?.name} as='span' className='p-0 d-flex align-items-center' onClick={e => e.preventDefault()}>
{`@${me?.name}`}<CowboyHat user={me} />
className={styles.dropdown}
title={
<Nav.Link eventKey={me.name} as='span' className='p-0 d-flex align-items-center' onClick={e => e.preventDefault()}>
{`@${me.name}`}<CowboyHat user={me} />
</Nav.Link>
} alignRight
}
alignRight
>
<Link href={'/' + me?.name} passHref>
<NavDropdown.Item active={me?.name === dropNavKey}>
<Link href={'/' + me.name} passHref legacyBehavior>
<NavDropdown.Item active={me.name === dropNavKey}>
profile
{me && !me.bioId &&
<div className='p-1 d-inline-block bg-secondary ml-1'>
@ -118,58 +91,61 @@ export default function Header ({ sub }) {
</div>}
</NavDropdown.Item>
</Link>
<Link href={'/' + me?.name + '/bookmarks'} passHref>
<NavDropdown.Item active={me?.name + '/bookmarks' === dropNavKey}>bookmarks</NavDropdown.Item>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<NavDropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</NavDropdown.Item>
</Link>
<Link href='/wallet' passHref>
<Link href='/wallet' passHref legacyBehavior>
<NavDropdown.Item eventKey='wallet'>wallet</NavDropdown.Item>
</Link>
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref>
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
<NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
</Link>
<NavDropdown.Divider />
<Link href='/referrals/month' passHref>
<Link href='/referrals/month' passHref legacyBehavior>
<NavDropdown.Item eventKey='referrals'>referrals</NavDropdown.Item>
</Link>
<NavDropdown.Divider />
<div className='d-flex align-items-center'>
<Link href='/settings' passHref>
<Link href='/settings' passHref legacyBehavior>
<NavDropdown.Item eventKey='settings'>settings</NavDropdown.Item>
</Link>
</div>
<NavDropdown.Divider />
<NavDropdown.Item onClick={() => signOut({ callbackUrl: '/' })}>logout</NavDropdown.Item>
</NavDropdown>
{me && !me.bioId &&
{!me.bioId &&
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px' }}>
<span className='invisible'>{' '}</span>
</span>}
</div>
{me &&
<Nav.Item>
<Link href='/wallet' passHref>
<Link href='/wallet' passHref legacyBehavior>
<Nav.Link eventKey='wallet' className='text-success px-0 text-nowrap'><WalletSummary me={me} /></Nav.Link>
</Link>
</Nav.Item>}
</Nav.Item>
</div>
)
} else {
if (!fired) {
}
function LurkerCorner ({ path }) {
const router = useRouter()
const strike = useLightning()
useEffect(() => {
let isMounted = true
if (!localStorage.getItem('striked')) {
setTimeout(() => {
if (isMounted) {
const to = setTimeout(() => {
strike()
localStorage.setItem('striked', 'yep')
setFired(true)
}
}, randInRange(3000, 10000))
return () => clearTimeout(to)
}
return () => { isMounted = false }
}, [])
}
const handleLogin = useCallback(async pathname => await router.push({
pathname,
query: { callbackUrl: window.location.origin + router.asPath }
}), [router])
return path !== '/login' && path !== '/signup' && !path.startsWith('/invites') &&
<div className='ml-auto'>
<Button
@ -177,7 +153,7 @@ export default function Header ({ sub }) {
id='signup'
style={{ borderWidth: '2px' }}
variant='outline-grey-darkmode'
onClick={async () => await router.push({ pathname: '/login', query: { callbackUrl: window.location.origin + router.asPath } })}
onClick={() => handleLogin('/login')}
>
login
</Button>
@ -185,7 +161,7 @@ export default function Header ({ sub }) {
className='align-items-center pl-2 py-1 pr-3'
style={{ borderWidth: '2px' }}
id='login'
onClick={async () => await router.push({ pathname: '/signup', query: { callbackUrl: window.location.origin + router.asPath } })}
onClick={() => handleLogin('/signup')}
>
<LightningIcon
width={17}
@ -195,77 +171,68 @@ export default function Header ({ sub }) {
</Button>
</div>
}
}
// const showJobIndicator = sub !== 'jobs' && (!me || me.noteJobIndicator) &&
// (!lastCheckedJobs || lastCheckedJobs < subLatestPost?.subLatestPost)
function NavItems ({ className, sub, prefix }) {
const router = useRouter()
sub ||= 'home'
const NavItems = ({ className }) => {
return (
<>
<Nav.Item className={className}>
<Form
initial={{
sub: sub || 'home'
}}
initial={{ sub }}
>
<Select
groupClassName='mb-0'
onChange={(formik, e) => router.push(e.target.value === 'home' ? '/' : `/~${e.target.value}`)}
name='sub'
size='sm'
overrideValue={sub}
items={['home', ...SUBS]}
/>
</Form>
</Nav.Item>
<Nav.Item className={className}>
<Link href={prefix + '/'} passHref>
<Link href={prefix + '/'} passHref legacyBehavior>
<Nav.Link eventKey='' className={styles.navLink}>hot</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item className={className}>
<Link href={prefix + '/recent'} passHref>
<Link href={prefix + '/recent'} passHref legacyBehavior>
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
</Link>
</Nav.Item>
{sub !== 'jobs' &&
<Nav.Item className={className}>
<Link href={prefix + '/top/posts/day'} passHref>
<Link href={prefix + '/top/posts/day'} passHref legacyBehavior>
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
</Link>
</Nav.Item>}
{/* <Nav.Item className={className}>
<div className='position-relative'>
<Link href='/~jobs' passHref>
<Nav.Link active={sub === 'jobs'} className={styles.navLink}>
jobs
</Nav.Link>
</Link>
{showJobIndicator &&
<span className={styles.jobIndicator}>
<span className='invisible'>{' '}</span>
</span>}
</div>
</Nav.Item> */}
{/* <Nav.Item className={`text-monospace nav-link mx-auto px-0 ${me?.name.length > 6 ? 'd-none d-lg-flex' : ''}`}>
<Price />
</Nav.Item> */}
</>
)
}
const PostItem = ({ className }) => {
return me
? (
<Link href={prefix + '/post'} passHref>
<a className={`${className} btn btn-md btn-primary px-3 py-1 `}>post</a>
</Link>)
: null
}
function PostItem ({ className, prefix }) {
const me = useMe()
if (!me) return null
return (
<>
<Container className='px-0'>
<Link href={prefix + '/post'} className={`${className} btn btn-md btn-primary px-3 py-1 `}>
post
</Link>
)
}
export default function Header ({ sub }) {
const router = useRouter()
const path = router.asPath.split('?')[0]
const prefix = sub ? `/~${sub}` : ''
const topNavKey = path.split('/')[sub ? 2 : 1] ?? ''
const dropNavKey = path.split('/').slice(sub ? 2 : 1).join('/')
const me = useMe()
return (
<Container as='header' className='px-0'>
<Navbar className='pb-0 pb-lg-2'>
<Nav
className={styles.navbarNav}
@ -273,15 +240,15 @@ export default function Header ({ sub }) {
>
<div className='d-flex align-items-center'>
<Back />
<Link href='/' passHref>
<Link href='/' passHref legacyBehavior>
<Navbar.Brand className={`${styles.brand} d-flex`}>
SN
</Navbar.Brand>
</Link>
</div>
<NavItems className='d-none d-lg-flex mx-2' />
<PostItem className='d-none d-lg-flex mx-2' />
<Link href='/search' passHref>
<NavItems className='d-none d-lg-flex mx-2' prefix={prefix} sub={sub} />
<PostItem className='d-none d-lg-flex mx-2' prefix={prefix} />
<Link href={prefix + '/search'} passHref legacyBehavior>
<Nav.Link eventKey='search' className='position-relative d-none d-lg-flex align-items-center pr-0 ml-2'>
<SearchIcon className='theme' width={22} height={22} />
</Nav.Link>
@ -289,7 +256,7 @@ export default function Header ({ sub }) {
<Nav.Item className={`${styles.price} ml-auto align-items-center ${me?.name.length > 10 ? 'd-none d-lg-flex' : ''}`}>
<Price className='nav-link text-monospace' />
</Nav.Item>
<Corner />
{me ? <StackerCorner dropNavKey={dropNavKey} /> : <LurkerCorner path={path} />}
</Nav>
</Navbar>
<Navbar className='pt-0 pb-2 d-lg-none'>
@ -297,36 +264,35 @@ export default function Header ({ sub }) {
className={`${styles.navbarNav}`}
activeKey={topNavKey}
>
<NavItems className='mr-1' />
<Link href='/search' passHref>
<NavItems className='mr-1' prefix={prefix} sub={sub} />
<Link href={prefix + '/search'} passHref legacyBehavior>
<Nav.Link eventKey='search' className='position-relative ml-auto d-flex mr-1'>
<SearchIcon className='theme' width={22} height={22} />
</Nav.Link>
</Link>
<PostItem className='mr-0 pr-0' />
<PostItem className='mr-0 pr-0' prefix={prefix} />
</Nav>
</Navbar>
</Container>
</>
)
}
export function HeaderStatic () {
return (
<Container className='px-sm-0'>
<Container as='header' className='px-sm-0'>
<Navbar className='pb-0 pb-lg-1'>
<Nav
className={styles.navbarNav}
>
<div className='d-flex align-items-center'>
<Back />
<Link href='/' passHref>
<Link href='/' passHref legacyBehavior>
<Navbar.Brand className={`${styles.brand}`}>
SN
</Navbar.Brand>
</Link>
<Link href='/search' passHref>
<Link href='/search' passHref legacyBehavior>
<Nav.Link eventKey='search' className='position-relative d-flex align-items-center mx-2'>
<SearchIcon className='theme' width={22} height={22} />
</Nav.Link>

View File

@ -1,28 +1,16 @@
import { useState } from 'react'
import { Modal } from 'react-bootstrap'
import InfoIcon from '../svgs/information-fill.svg'
import { useShowModal } from './modal'
export default function Info ({ children, iconClassName = 'fill-theme-color' }) {
const [info, setInfo] = useState()
const showModal = useShowModal()
return (
<>
<Modal
show={info}
onHide={() => setInfo(false)}
>
<div className='modal-close' onClick={() => setInfo(false)}>X</div>
<Modal.Body>
{children}
</Modal.Body>
</Modal>
<InfoIcon
width={18} height={18} className={`${iconClassName} pointer ml-1`}
onClick={(e) => {
e.preventDefault()
setInfo(true)
showModal(onClose => children)
}}
/>
</>
)
}

View File

@ -5,12 +5,13 @@ import Comment from './comment'
import Text, { ZoomableImage } from './text'
import Comments from './comments'
import styles from '../styles/item.module.css'
import itemStyles from './item.module.css'
import { NOFOLLOW_LIMIT } from '../lib/constants'
import { useMe } from './me'
import { Button } from 'react-bootstrap'
import { TwitterTweetEmbed } from 'react-twitter-embed'
import YouTube from 'react-youtube'
import useDarkMode from 'use-dark-mode'
import useDarkMode from './dark-mode'
import { useEffect, useState } from 'react'
import Poll from './poll'
import { commentsViewed } from '../lib/new-comments'
@ -61,7 +62,7 @@ function TweetSkeleton () {
}
function ItemEmbed ({ item }) {
const darkMode = useDarkMode()
const [darkMode] = useDarkMode()
const [overflowing, setOverflowing] = useState(false)
const [show, setShow] = useState(false)
@ -69,7 +70,7 @@ function ItemEmbed ({ item }) {
if (twitter?.groups?.id) {
return (
<div className={`${styles.twitterContainer} ${show ? '' : styles.twitterContained}`}>
<TwitterTweetEmbed tweetId={twitter.groups.id} options={{ width: '550px', theme: darkMode.value ? 'dark' : 'light' }} placeholder={<TweetSkeleton />} onLoad={() => setOverflowing(true)} />
<TwitterTweetEmbed tweetId={twitter.groups.id} options={{ width: '550px', theme: darkMode ? 'dark' : 'light' }} placeholder={<TweetSkeleton />} onLoad={() => setOverflowing(true)} />
{overflowing && !show &&
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
show full tweet
@ -104,8 +105,8 @@ function FwdUser ({ user }) {
return (
<div className={styles.other}>
100% of zaps are forwarded to{' '}
<Link href={`/${user.name}`} passHref>
<a>@{user.name}</a>
<Link href={`/${user.name}`}>
@{user.name}
</Link>
</div>
)
@ -119,6 +120,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
item={item}
full
right={
!noReply &&
<>
<Share item={item} />
<Toc text={item.text} />
@ -158,12 +160,19 @@ function ItemText ({ item }) {
return <Text topLevel nofollow={item.sats + item.boost < NOFOLLOW_LIMIT}>{item.searchText || item.text}</Text>
}
export default function ItemFull ({ item, bio, ...props }) {
export default function ItemFull ({ item, bio, rank, ...props }) {
useEffect(() => {
commentsViewed(item)
}, [item.lastCommentAt])
return (
<>
{rank
? (
<div className={`${itemStyles.rank} pt-2 align-self-start`}>
{rank}
</div>)
: <div />}
<RootProvider root={item.root || item}>
{item.parentId
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
@ -176,8 +185,12 @@ export default function ItemFull ({ item, bio, ...props }) {
</div>)}
{item.comments &&
<div className={styles.comments}>
<Comments parentId={item.id} pinned={item.position} commentSats={item.commentSats} comments={item.comments} />
<Comments
parentId={item.id} parentCreatedAt={item.createdAt}
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
/>
</div>}
</RootProvider>
</>
)
}

View File

@ -41,43 +41,39 @@ export default function ItemInfo ({ item, pendingSats, full, commentsText, class
<span>{abbrNum(item.boost)} boost</span>
<span> \ </span>
</>}
<Link href={`/items/${item.id}`} passHref>
<a title={`${item.commentSats} sats`} className='text-reset'>
<Link href={`/items/${item.id}`} title={`${item.commentSats} sats`} className='text-reset'>
{item.ncomments} {commentsText || 'comments'}
{hasNewComments && <>{' '}<Badge className={styles.newComment} variant={null}>new</Badge></>}
</a>
</Link>
<span> \ </span>
<span>
<Link href={`/${item.user.name}`} passHref>
<a className='d-inline-flex align-items-center'>
<Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'>
@{item.user.name}<CowboyHat className='ml-1 fill-grey' user={item.user} height={12} width={12} />
{embellishUser}
</a>
</Link>
<span> </span>
<Link href={`/items/${item.id}`} passHref>
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
{timeSince(new Date(item.createdAt))}
</Link>
{item.prior &&
<>
<span> \ </span>
<Link href={`/items/${item.prior}`} passHref>
<a className='text-reset'>yesterday</a>
<Link href={`/items/${item.prior}`} className='text-reset'>
yesterday
</Link>
</>}
</span>
{item.subName &&
<Link href={`/~${item.subName}`}>
<a>{' '}<Badge className={styles.newComment} variant={null}>{item.subName}</Badge></a>
{' '}<Badge className={styles.newComment} variant={null}>{item.subName}</Badge>
</Link>}
{(item.outlawed && !item.mine &&
<Link href='/outlawed'>
<a>{' '}<Badge className={styles.newComment} variant={null}>outlawed</Badge></a>
<Link href='/recent/outlawed'>
{' '}<Badge className={styles.newComment} variant={null}>outlawed</Badge>
</Link>) ||
(item.freebie && !item.mine &&
<Link href='/freebie'>
<a>{' '}<Badge className={styles.newComment} variant={null}>freebie</Badge></a>
(item.freebie &&
<Link href='/recent/freebies'>
{' '}<Badge className={styles.newComment} variant={null}>freebie</Badge>
</Link>
)}
{canEdit && !item.deletedAt &&
@ -101,11 +97,9 @@ export default function ItemInfo ({ item, pendingSats, full, commentsText, class
{me && <BookmarkDropdownItem item={item} />}
{me && item.user.id !== me.id && <SubscribeDropdownItem item={item} />}
{item.otsHash &&
<Dropdown.Item>
<Link passHref href={`/items/${item.id}/ots`}>
<a className='text-reset'>ots timestamp</a>
</Link>
</Dropdown.Item>}
<Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'>
ots timestamp
</Link>}
{me && !item.meSats && !item.position && !item.meDontLike &&
!item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />}
{item.mine && !item.position && !item.deletedAt &&

View File

@ -21,22 +21,18 @@ export default function ItemJob ({ item, toc, rank, children }) {
</div>)
: <div />}
<div className={`${styles.item}`}>
<Link href={`/items/${item.id}`} passHref>
<a>
<Link href={`/items/${item.id}`}>
<Image
src={item.uploadId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${item.uploadId}` : '/jobs-default.png'} width='42' height='42' className={styles.companyImage}
/>
</a>
</Link>
<div className={`${styles.hunk} align-self-center mb-0`}>
<div className={`${styles.main} flex-wrap d-inline`}>
<Link href={`/items/${item.id}`} passHref>
<a className={`${styles.title} text-reset mr-2`}>
<Link href={`/items/${item.id}`} className={`${styles.title} text-reset mr-2`}>
{item.searchTitle
? <SearchTitle title={item.searchTitle} />
: (
<>{item.title}</>)}
</a>
</Link>
</div>
<div className={`${styles.other}`}>
@ -52,14 +48,12 @@ export default function ItemJob ({ item, toc, rank, children }) {
<wbr />
<span> \ </span>
<span>
<Link href={`/${item.user.name}`} passHref>
<a className='d-inline-flex align-items-center'>
<Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'>
@{item.user.name}<CowboyHat className='ml-1 fill-grey' user={item.user} height={12} width={12} />
</a>
</Link>
<span> </span>
<Link href={`/items/${item.id}`} passHref>
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset'>
{timeSince(new Date(item.createdAt))}
</Link>
</span>
{item.mine &&
@ -67,10 +61,8 @@ export default function ItemJob ({ item, toc, rank, children }) {
<>
<wbr />
<span> \ </span>
<Link href={`/items/${item.id}/edit`} passHref>
<a className='text-reset'>
<Link href={`/items/${item.id}/edit`} className='text-reset'>
edit
</a>
</Link>
{item.status !== 'ACTIVE' && <span className='ml-1 font-weight-bold text-boost'> {item.status}</span>}
</>)}

View File

@ -19,7 +19,7 @@ export function SearchTitle ({ title }) {
})
}
export default function Item ({ item, rank, belowTitle, right, full, children }) {
export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments }) {
const titleRef = useRef()
const [pendingSats, setPendingSats] = useState(0)
@ -33,14 +33,13 @@ export default function Item ({ item, rank, belowTitle, right, full, children })
{rank}
</div>)
: <div />}
<div className={styles.item}>
<div className={`${styles.item} ${siblingComments ? 'pt-2' : ''}`}>
{item.position
? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLike ? <Flag width={24} height={24} className={`${styles.dontLike}`} /> : <UpVote item={item} className={styles.upvote} pendingSats={pendingSats} setPendingSats={setPendingSats} />}
<div className={styles.hunk}>
<div className={`${styles.main} flex-wrap`}>
<Link href={`/items/${item.id}`} passHref>
<a ref={titleRef} className={`${styles.title} text-reset mr-2`}>
<Link href={`/items/${item.id}`} ref={titleRef} className={`${styles.title} text-reset mr-2`}>
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
{item.pollCost && <span className={styles.icon}> <PollIcon className='fill-grey ml-1' height={14} width={14} /></span>}
{item.bounty > 0 &&
@ -50,7 +49,6 @@ export default function Item ({ item, rank, belowTitle, right, full, children })
</ActionTooltip>
</span>}
{image && <span className={styles.icon}><ImageIcon className='fill-grey ml-2' height={16} width={16} /></span>}
</a>
</Link>
{item.url && !image &&
<>

View File

@ -70,7 +70,7 @@ a.link:visited {
display: flex;
justify-content: flex-start;
min-width: 0;
padding-bottom: .45rem;
padding-bottom: .5rem;
}
.item .companyImage {
@ -120,7 +120,7 @@ a.link:visited {
.rank {
font-weight: 600;
margin-top: 4px;
margin-top: .25rem;
display: flex;
color: var(--theme-grey);
font-size: 90%;

View File

@ -1,34 +0,0 @@
import { Fragment } from 'react'
import { CommentFlat } from './comment'
import Item from './item'
import ItemJob from './item-job'
import { ItemsSkeleton } from './items'
import styles from './items.module.css'
import MoreFooter from './more-footer'
export default function MixedItems ({ rank, items, cursor, fetchMore }) {
return (
<>
<div className={styles.grid}>
{items.map((item, i) => (
<Fragment key={item.id}>
{item.parentId
? (
<><div />
<div className='pb-1 mb-1'>
<CommentFlat item={item} noReply includeParent clickToContext />
</div>
</>)
: (item.isJob
? <ItemJob item={item} rank={rank && i + 1} />
: <Item item={item} rank={rank && i + 1} />)}
</Fragment>
))}
</div>
<MoreFooter
cursor={cursor} fetchMore={fetchMore}
Skeleton={() => <ItemsSkeleton rank={rank} startRank={items.length} />}
/>
</>
)
}

View File

@ -2,57 +2,63 @@ import { useQuery } from '@apollo/client'
import Item, { ItemSkeleton } from './item'
import ItemJob from './item-job'
import styles from './items.module.css'
import { ITEMS } from '../fragments/items'
import MoreFooter from './more-footer'
import { Fragment } from 'react'
import { Fragment, useCallback, useMemo } from 'react'
import { CommentFlat } from './comment'
import { SUB_ITEMS } from '../fragments/subs'
import { LIMIT } from '../lib/cursor'
import ItemFull from './item-full'
export default function Items ({ variables = {}, query, destructureData, rank, items, pins, cursor }) {
const { data, fetchMore } = useQuery(query || ITEMS, { variables })
export default function Items ({ ssrData, variables = {}, query, destructureData, rank, noMoreText, Footer, filter = () => true }) {
const { data, fetchMore } = useQuery(query || SUB_ITEMS, { variables })
const Foooter = Footer || MoreFooter
if (!data && !items) {
return <ItemsSkeleton rank={rank} />
}
if (data) {
const { items, pins, cursor } = useMemo(() => {
if (!data && !ssrData) return {}
if (destructureData) {
({ items, pins, cursor } = destructureData(data))
return destructureData(data || ssrData)
} else {
({ items: { items, pins, cursor } } = data)
}
return data?.items || ssrData?.items
}
}, [data, ssrData])
const pinMap = pins?.reduce((a, p) => { a[p.position] = p; return a }, {})
const pinMap = useMemo(() =>
pins?.reduce((a, p) => { a[p.position] = p; return a }, {}), [pins])
const Skeleton = useCallback(() =>
<ItemsSkeleton rank={rank} startRank={items?.length} limit={variables.limit} />, [rank, items])
if (!ssrData && !data) {
return <Skeleton />
}
return (
<>
<div className={styles.grid}>
{items.map((item, i) => (
{items.filter(filter).map((item, i) => (
<Fragment key={item.id}>
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />}
{item.parentId
? <><div /><div className='pb-3'><CommentFlat item={item} noReply includeParent /></div></>
? <CommentFlat item={item} rank={rank && i + 1} noReply includeParent clickToContext />
: (item.isJob
? <ItemJob item={item} rank={rank && i + 1} />
: (item.title
? <Item item={item} rank={rank && i + 1} />
: (
<div className='pb-2'>
<CommentFlat item={item} noReply includeParent clickToContext />
</div>)))}
: (item.searchText
? <ItemFull item={item} rank={rank && i + 1} noReply siblingComments={variables.includeComments} />
: <Item item={item} rank={rank && i + 1} siblingComments={variables.includeComments} />))}
</Fragment>
))}
</div>
<MoreFooter
cursor={cursor} fetchMore={fetchMore}
Skeleton={() => <ItemsSkeleton rank={rank} startRank={items.length} />}
<Foooter
cursor={cursor} fetchMore={fetchMore} noMoreText={noMoreText}
count={items?.length}
Skeleton={Skeleton}
/>
</>
)
}
export function ItemsSkeleton ({ rank, startRank = 0 }) {
const items = new Array(21).fill(null)
export function ItemsSkeleton ({ rank, startRank = 0, limit = LIMIT }) {
const items = new Array(limit).fill(null)
return (
<div className={styles.grid}>

View File

@ -1,5 +1,4 @@
import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form'
import TextareaAutosize from 'react-textarea-autosize'
import { InputGroup, Form as BForm, Col, Image } from 'react-bootstrap'
import { useEffect, useState } from 'react'
import Info from './info'
@ -133,7 +132,6 @@ export default function JobForm ({ item, sub }) {
topLevel
label='description'
name='text'
as={TextareaAutosize}
minRows={6}
required
/>
@ -170,7 +168,7 @@ function PromoteJob ({ item, sub, storageKeyPrefix }) {
query AuctionPosition($id: ID, $bid: Int!) {
auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, bid: $bid)
}`,
{ fetchPolicy: 'network-only' })
{ fetchPolicy: 'cache-and-network' })
const position = data?.auctionPosition
useEffect(() => {
@ -260,7 +258,7 @@ function StatusControl ({ item }) {
<div className='p-3'>
<BootstrapForm.Label>job control</BootstrapForm.Label>
{item.status === 'NOSATS' &&
<Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' passHref><a className='text-reset text-underline'>fund your wallet</a></Link> or reduce bid to continue promoting your job</Alert>}
<Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' className='text-reset text-underline'>fund your wallet</Link> or reduce bid to continue promoting your job</Alert>}
<StatusComp />
</div>
</div>

View File

@ -1,14 +0,0 @@
import Layout from './layout'
import styles from './layout-center.module.css'
export default function LayoutCenter ({ children, footerLinks, ...props }) {
return (
<div className={styles.page}>
<Layout noContain noFooterLinks={!footerLinks} {...props}>
<div className={styles.content}>
{children}
</div>
</Layout>
</div>
)
}

View File

@ -1,15 +0,0 @@
import Footer from './footer'
import { HeaderStatic } from './header'
import styles from './layout-center.module.css'
export default function LayoutStatic ({ children, ...props }) {
return (
<div className={styles.page}>
<HeaderStatic />
<div className={`${styles.content} pt-5`}>
{children}
</div>
<Footer />
</div>
)
}

View File

@ -1,29 +1,60 @@
import Header from './header'
import Header, { HeaderStatic } from './header'
import Container from 'react-bootstrap/Container'
import { LightningProvider } from './lightning'
import Footer from './footer'
import Seo from './seo'
import Seo, { SeoSearch } from './seo'
import Search from './search'
import styles from './layout.module.css'
export default function Layout ({
sub, noContain, noFooter, noFooterLinks,
containClassName, noSeo, children, search
sub, contain = true, footer = true, footerLinks = true,
containClassName = '', seo = true, item, user, children
}) {
return (
<>
{!noSeo && <Seo sub={sub} />}
<LightningProvider>
{seo && <Seo sub={sub} item={item} user={user} />}
<Header sub={sub} />
{noContain
? children
: (
<Container className={`px-sm-0 ${containClassName || ''}`}>
{contain
? (
<Container as='main' className={`px-sm-0 ${containClassName}`}>
{children}
</Container>
)}
{!noFooter && <Footer noLinks={noFooterLinks} />}
{!noContain && search && <Search sub={sub} />}
</LightningProvider>
)
: children}
{footer && <Footer links={footerLinks} />}
</>
)
}
export function SearchLayout ({ sub, children, ...props }) {
return (
<Layout sub={sub} seo={false} footer={false} {...props}>
<SeoSearch sub={sub} />
{children}
<Search />
</Layout>
)
}
export function StaticLayout ({ children, footer = true, footerLinks, ...props }) {
return (
<div className={styles.page}>
<HeaderStatic />
<main className={`${styles.content} pt-5`}>
{children}
</main>
{footer && <Footer links={footerLinks} />}
</div>
)
}
export function CenterLayout ({ children, ...props }) {
return (
<div className={styles.page}>
<Layout contain={false} {...props}>
<main className={styles.content}>
{children}
</main>
</Layout>
</div>
)
}

View File

@ -16,7 +16,7 @@ function QrAuth ({ k1, encodedUrl, slashtagUrl, callbackUrl }) {
k1
}
}`
const { data } = useQuery(query, { pollInterval: 1000 })
const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
if (data && data.lnAuth.pubkey) {
signIn(encodedUrl ? 'lightning' : 'slashtags', { ...data.lnAuth, callbackUrl })

View File

@ -1,32 +1,37 @@
import React, { useRef, useEffect, useContext } from 'react'
import { randInRange } from '../lib/rand'
export const LightningContext = React.createContext({
bolts: 0,
strike: () => {}
})
export const LightningContext = React.createContext(() => {})
export class LightningProvider extends React.Component {
state = {
bolts: 0,
strike: (repeat) => {
bolts: []
}
strike = () => {
const should = localStorage.getItem('lnAnimate') || 'yes'
if (should === 'yes') {
this.setState(state => {
return {
...this.state,
bolts: this.state.bolts + 1
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
}
})
}
}
unstrike = (index) => {
this.setState(state => {
const bolts = [...state.bolts]
bolts[index] = null
return { bolts }
})
}
render () {
const { state, props: { children } } = this
const { props: { children } } = this
return (
<LightningContext.Provider value={state}>
{new Array(this.state.bolts).fill(null).map((_, i) => <Lightning key={i} />)}
<LightningContext.Provider value={this.strike}>
{this.state.bolts}
{children}
</LightningContext.Provider>
)
@ -35,31 +40,33 @@ export class LightningProvider extends React.Component {
export const LightningConsumer = LightningContext.Consumer
export function useLightning () {
const { strike } = useContext(LightningContext)
return strike
return useContext(LightningContext)
}
export function Lightning () {
export function Lightning ({ onDone }) {
const canvasRef = useRef(null)
useEffect(() => {
const canvas = canvasRef.current
if (canvas.bolt) return
const context = canvas.getContext('2d')
canvas.width = window.innerWidth
canvas.height = window.innerHeight
const bolt = new Bolt(context, {
canvas.bolt = new Bolt(context, {
startPoint: [Math.random() * (canvas.width * 0.5) + (canvas.width * 0.25), 0],
length: canvas.height,
speed: 100,
spread: 30,
branches: 20
branches: 20,
onDone
})
bolt.draw()
canvas.bolt.draw()
}, [])
return <canvas className='position-fixed' ref={canvasRef} style={{ zIndex: 0, pointerEvents: 'none' }} />
return <canvas className='position-fixed' ref={canvasRef} style={{ zIndex: 100, pointerEvents: 'none' }} />
}
function Bolt (ctx, options) {
@ -79,12 +86,6 @@ function Bolt (ctx, options) {
this.lastAngle = this.options.angle
this.children = []
const radians = this.options.angle * Math.PI / 180
this.endPoint = [
this.options.startPoint[0] + Math.cos(radians) * this.options.length,
this.options.startPoint[1] + Math.sin(radians) * this.options.length
]
ctx.shadowColor = 'rgba(250, 218, 94, 1)'
ctx.shadowBlur = 5
ctx.shadowOffsetX = 0
@ -92,6 +93,7 @@ function Bolt (ctx, options) {
ctx.fillStyle = 'rgba(250, 250, 250, 1)'
ctx.strokeStyle = 'rgba(250, 218, 94, 1)'
ctx.lineWidth = this.options.lineWidth
this.draw = (isChild) => {
ctx.beginPath()
ctx.moveTo(this.point[0], this.point[1])
@ -110,9 +112,6 @@ function Bolt (ctx, options) {
Math.pow(this.point[1] - this.options.startPoint[1], 2)
)
// make skinnier?
// ctx.lineWidth = ctx.lineWidth * 0.98
if (randInRange(0, 99) < this.options.branches && this.children.length < this.options.maxBranches) {
this.children.push(new Bolt(ctx, {
startPoint: [this.point[0], this.point[1]],
@ -146,6 +145,7 @@ function Bolt (ctx, options) {
ctx.canvas.style.opacity -= 0.04
if (ctx.canvas.style.opacity <= 0) {
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
this.options.onDone()
return
}

View File

@ -29,9 +29,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
title
unshorted
}
}`, {
fetchPolicy: 'network-only'
})
}`)
const [getDupes, { data: dupesData, loading: dupesLoading }] = useLazyQuery(gql`
${ITEM_FIELDS}
query Dupes($url: String!) {
@ -39,7 +37,6 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
...ItemFields
}
}`, {
fetchPolicy: 'network-only',
onCompleted: () => setPostDisabled(false)
})
const [getRelated, { data: relatedData }] = useLazyQuery(gql`
@ -50,9 +47,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
...ItemFields
}
}
}`, {
fetchPolicy: 'network-only'
})
}`)
const related = []
for (const item of relatedData?.related?.items || []) {

View File

@ -15,14 +15,15 @@ export default function LoginButton ({ text, type, className, onClick }) {
Icon = GithubIcon
variant = 'dark'
break
case 'lightning':
Icon = LightningIcon
variant = 'primary'
break
case 'slashtags':
Icon = SlashtagsIcon
variant = 'grey-medium'
break
case 'lightning':
default:
Icon = LightningIcon
variant = 'primary'
break
}
const name = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase()

View File

@ -7,7 +7,7 @@ export const MeContext = React.createContext({
})
export function MeProvider ({ me, children }) {
const { data } = useQuery(ME, { pollInterval: 1000, fetchPolicy: 'cache-and-network' })
const { data } = useQuery(ME, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
const contextValue = {
me: data?.me || me

View File

@ -1,21 +0,0 @@
import { useState } from 'react'
import { Modal } from 'react-bootstrap'
export default function ModalButton ({ children, clicker }) {
const [show, setShow] = useState()
return (
<>
<Modal
show={show}
onHide={() => setShow(false)}
>
<div className='modal-close' onClick={() => setShow(false)}>X</div>
<Modal.Body>
{show && children}
</Modal.Body>
</Modal>
<div className='pointer' onClick={() => setShow(true)}>{clicker}</div>
</>
)
}

View File

@ -1,7 +1,8 @@
import { Button } from 'react-bootstrap'
import { useState } from 'react'
import Link from 'next/link'
export default function MoreFooter ({ cursor, fetchMore, Skeleton, noMoreText }) {
export default function MoreFooter ({ cursor, count, fetchMore, Skeleton, noMoreText = 'GENESIS' }) {
const [loading, setLoading] = useState(false)
if (loading) {
@ -28,9 +29,24 @@ export default function MoreFooter ({ cursor, fetchMore, Skeleton, noMoreText })
)
} else {
Footer = () => (
<div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>{noMoreText || 'GENESIS'}</div>
<div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>{count === 0 ? 'EMPTY' : noMoreText}</div>
)
}
return <div className='d-flex justify-content-center mt-3 mb-1'><Footer /></div>
}
export function NavigateFooter ({ cursor, count, fetchMore, href, text, noMoreText = 'NO MORE' }) {
let Footer
if (cursor) {
Footer = () => (
<Link href={href} className='text-reset text-muted font-weight-bold'>{text}</Link>
)
} else {
Footer = () => (
<div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>{count === 0 ? 'EMPTY' : noMoreText}</div>
)
}
return <div className='d-flex justify-content-start my-1'><Footer /></div>
}

View File

@ -1,10 +1,9 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@apollo/client'
import { useState, useEffect, useMemo } from 'react'
import { useApolloClient, useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment'
import Item from './item'
import ItemJob from './item-job'
import { NOTIFICATIONS } from '../fragments/notifications'
import { useRouter } from 'next/router'
import { HAS_NOTIFICATIONS, NOTIFICATIONS } from '../fragments/notifications'
import MoreFooter from './more-footer'
import Invite from './invite'
import { ignoreClick } from '../lib/clicks'
@ -20,6 +19,7 @@ import { Alert } from 'react-bootstrap'
import styles from './notifications.module.css'
import { useServiceWorker } from './serviceworker'
import { Checkbox, Form } from './form'
import { useRouter } from 'next/router'
function Notification ({ n }) {
switch (n.__typename) {
@ -37,39 +37,47 @@ function Notification ({ n }) {
return null
}
function NotificationLayout ({ children, onClick }) {
function NotificationLayout ({ children, href, as }) {
const router = useRouter()
return (
<div
className='clickToContext' onClick={(e) => {
if (ignoreClick(e)) return
onClick?.(e)
}}
className='clickToContext'
onClick={(e) => !ignoreClick(e) && router.push(href, as)}
>
{children}
</div>
)
}
const defaultOnClick = (n, router) => () => {
const defaultOnClick = n => {
if (!n.item.title) {
const path = n.item.path.split('.')
if (path.length > COMMENT_DEPTH_LIMIT + 1) {
const rootId = path.slice(-(COMMENT_DEPTH_LIMIT + 1))[0]
router.push({
return {
href: {
pathname: '/items/[id]',
query: { id: rootId, commentId: n.item.id }
}, `/items/${rootId}`)
} else {
router.push({
pathname: '/items/[id]',
query: { id: n.item.root.id, commentId: n.item.id }
}, `/items/${n.item.root.id}`)
},
as: `/items/${rootId}`
}
} else {
router.push({
return {
href: {
pathname: '/items/[id]',
query: { id: n.item.root.id, commentId: n.item.id }
},
as: `/items/${n.item.root.id}`
}
}
} else {
return {
href: {
pathname: '/items/[id]',
query: { id: n.item.id }
}, `/items/${n.item.id}`)
},
as: `/items/${n.item.id}`
}
}
}
@ -114,8 +122,7 @@ function Streak ({ n }) {
function EarnNotification ({ n }) {
return (
<NotificationLayout>
<div className='d-flex'>
<div className='d-flex ml-2 py-1'>
<HandCoin className='align-self-center fill-boost mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} />
<div className='ml-2'>
<div className='font-weight-bold text-boost'>
@ -129,18 +136,16 @@ function EarnNotification ({ n }) {
{n.sources.tipComments > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0 || n.sources.tipPosts > 0) && ' \\ '}{n.sources.tipComments} sats for zapping top comments early</span>}
</div>}
<div className='pb-1' style={{ lineHeight: '140%' }}>
SN distributes the sats it earns back to its best stackers daily. These sats come from <Link href='/~jobs' passHref><a>jobs</a></Link>, boosts, posting fees, and donations. You can see the daily rewards pool and make a donation <Link href='/rewards' passHref><a>here</a></Link>.
SN distributes the sats it earns back to its best stackers daily. These sats come from <Link href='/~jobs'>jobs</Link>, boosts, posting fees, and donations. You can see the daily rewards pool and make a donation <Link href='/rewards'>here</Link>.
</div>
</div>
</div>
</NotificationLayout>
)
}
function Invitification ({ n }) {
const router = useRouter()
return (
<NotificationLayout onClick={() => router.push('/invites')}>
<NotificationLayout href='/invites'>
<small className='font-weight-bold text-secondary ml-2'>
your invite has been redeemed by {n.invite.invitees.length} stackers
</small>
@ -157,9 +162,8 @@ function Invitification ({ n }) {
}
function InvoicePaid ({ n }) {
const router = useRouter()
return (
<NotificationLayout onClick={() => router.push(`/invoices/${n.invoice.id}`)}>
<NotificationLayout href={`/invoices/${n.invoice.id}`}>
<div className='font-weight-bold text-info ml-2 py-1'>
<Check className='fill-info mr-1' />{n.earnedSats} sats were deposited in your account
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
@ -172,7 +176,7 @@ function Referral ({ n }) {
return (
<NotificationLayout>
<small className='font-weight-bold text-secondary ml-2'>
someone joined via one of your <Link href='/referrals/month' passHref><a className='text-reset'>referral links</a></Link>
someone joined via one of your <Link href='/referrals/month' className='text-reset'>referral links</Link>
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</small>
</NotificationLayout>
@ -180,9 +184,8 @@ function Referral ({ n }) {
}
function Votification ({ n }) {
const router = useRouter()
return (
<NotificationLayout onClick={defaultOnClick(n, router)}>
<NotificationLayout {...defaultOnClick(n)}>
<small className='font-weight-bold text-success ml-2'>
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
</small>
@ -202,9 +205,8 @@ function Votification ({ n }) {
}
function Mention ({ n }) {
const router = useRouter()
return (
<NotificationLayout onClick={defaultOnClick(n, router)}>
<NotificationLayout {...defaultOnClick(n)}>
<small className='font-weight-bold text-info ml-2'>
you were mentioned in
</small>
@ -223,9 +225,8 @@ function Mention ({ n }) {
}
function JobChanged ({ n }) {
const router = useRouter()
return (
<NotificationLayout onClick={defaultOnClick(n, router)}>
<NotificationLayout {...defaultOnClick(n)}>
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
{n.item.status === 'ACTIVE'
? 'your job is active again'
@ -239,9 +240,8 @@ function JobChanged ({ n }) {
}
function Reply ({ n }) {
const router = useRouter()
return (
<NotificationLayout onClick={defaultOnClick(n, router)} rootText='replying on:'>
<NotificationLayout {...defaultOnClick(n)} rootText='replying on:'>
<div className='py-2'>
{n.item.title
? <Item item={n.item} />
@ -274,8 +274,6 @@ function NotificationAlert () {
}
}, [sw])
if (!supported) return null
const close = () => {
localStorage.setItem('hideNotifyPrompt', 'yep')
setShowAlert(false)
@ -305,7 +303,7 @@ function NotificationAlert () {
</Alert>
)
: (
<Form className='d-flex justify-content-end' initial={{ pushNotify: hasSubscription }}>
<Form className={`d-flex justify-content-end ${supported ? 'visible' : 'invisible'}`} initial={{ pushNotify: hasSubscription }}>
<Checkbox
name='pushNotify' label={<span className='text-muted'>push notifications</span>}
groupClassName={`${styles.subFormGroup} mb-1 mr-sm-3 mr-0`}
@ -318,34 +316,48 @@ function NotificationAlert () {
)
}
export default function Notifications ({ notifications, earn, cursor, lastChecked, variables }) {
const { data, fetchMore } = useQuery(NOTIFICATIONS, { variables })
export default function Notifications ({ ssrData }) {
const { data, fetchMore } = useQuery(NOTIFICATIONS)
const client = useApolloClient()
if (data) {
({ notifications: { notifications, earn, cursor } } = data)
useEffect(() => {
client.writeQuery({
query: HAS_NOTIFICATIONS,
data: {
hasNewNotes: false
}
})
}, [client])
const [fresh, old] =
notifications.reduce((result, n) => {
const { notifications: { notifications, earn, lastChecked, cursor } } = useMemo(() => {
if (!data && !ssrData) return { notifications: {} }
return data || ssrData
}, [data, ssrData])
const [fresh, old] = useMemo(() => {
if (!notifications) return [[], []]
return notifications.reduce((result, n) => {
result[new Date(n.sortTime).getTime() > lastChecked ? 0 : 1].push(n)
return result
},
[[], []])
}, [notifications, lastChecked])
if (!data && !ssrData) return <CommentsFlatSkeleton />
return (
<>
<NotificationAlert />
{/* XXX we shouldn't use the index but we don't have a unique id in this union yet */}
<div className='fresh'>
{earn && <Notification n={earn} key='earn' />}
{fresh.map((n, i) => (
<Notification n={n} key={i} />
<Notification n={n} key={n.__typename + n.id + n.sortTime} />
))}
</div>
{old.map((n, i) => (
<Notification n={n} key={i} />
<Notification n={n} key={n.__typename + n.id + n.sortTime} />
))}
<MoreFooter cursor={cursor} fetchMore={fetchMore} Skeleton={CommentsFlatSkeleton} />
<MoreFooter cursor={cursor} count={notifications?.length} fetchMore={fetchMore} Skeleton={CommentsFlatSkeleton} noMoreText='NO MORE' />
</>
)
}
@ -354,7 +366,8 @@ function CommentsFlatSkeleton () {
const comments = new Array(21).fill(null)
return (
<div>{comments.map((_, i) => (
<div>
{comments.map((_, i) => (
<CommentSkeleton key={i} skeletonChildren={0} />
))}
</div>

View File

@ -0,0 +1,9 @@
import Moon from '../svgs/moon-fill.svg'
export default function PageLoading () {
return (
<div className='d-flex justify-content-center mt-3 mb-1'>
<Moon className='spin fill-grey' />
</div>
)
}

View File

@ -1,44 +1,26 @@
import { useQuery } from '@apollo/client'
import AccordianItem from './accordian-item'
import Item, { ItemSkeleton } from './item'
import { BOUNTY_ITEMS_BY_USER_NAME } from '../fragments/items'
import Link from 'next/link'
import styles from './items.module.css'
import Items from './items'
import { NavigateFooter } from './more-footer'
export default function PastBounties ({ children, item }) {
const emptyItems = new Array(5).fill(null)
const LIMIT = 5
const { data, loading } = useQuery(BOUNTY_ITEMS_BY_USER_NAME, {
variables: {
export default function PastBounties ({ item }) {
const variables = {
name: item.user.name,
limit: 5
},
fetchPolicy: 'cache-first'
})
let items, cursor
if (data) {
({ getBountiesByUserName: { items, cursor } } = data)
items = items.filter(i => i.id !== item.id)
sort: 'user',
type: 'bounties',
limit: LIMIT
}
return (
<AccordianItem
header={<div className='font-weight-bold'>{item.user.name}'s bounties</div>}
body={
<>
<div className={styles.grid}>
{loading
? emptyItems.map((_, i) => <ItemSkeleton key={i} />)
: (items?.length
? items.map(bountyItem => {
return <Item key={bountyItem.id} item={bountyItem} />
})
: <div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>EMPTY</div>
)}
</div>
{cursor && <Link href={`/${item.user.name}/bounties`} query={{ parent: item }} passHref><a className='text-reset text-muted font-weight-bold'>view all past bounties</a></Link>}
</>
<Items
variables={variables}
Footer={props => <NavigateFooter {...props} href={`/${item.user.name}/bounties`} text='view all past bounties' />}
filter={i => i.id !== item.id}
/>
}
/>
)

View File

@ -2,7 +2,6 @@ import React from 'react'
import { Button } from 'react-bootstrap'
import styles from './pay-bounty.module.css'
import ActionTooltip from './action-tooltip'
import ModalButton from './modal-button'
import { useMutation, gql } from '@apollo/client'
import { useMe } from './me'
import { abbrNum } from '../lib/format'
@ -61,7 +60,7 @@ export default function PayBounty ({ children, item }) {
}
)
const handlePayBounty = async () => {
const handlePayBounty = async onComplete => {
try {
await act({
variables: { id: item.id, sats: root.bounty },
@ -72,6 +71,7 @@ export default function PayBounty ({ children, item }) {
}
}
})
onComplete()
} catch (error) {
if (error.toString().includes('insufficient funds')) {
showModal(onClose => {
@ -92,22 +92,24 @@ export default function PayBounty ({ children, item }) {
notForm
overlayText={`${root.bounty} sats`}
>
<ModalButton
clicker={
<div className={styles.pay}>
pay bounty
</div>
}
>
<div
className={styles.pay} onClick={() => {
showModal(onClose => (
<>
<div className='text-center font-weight-bold text-muted'>
Pay this bounty to {item.user.name}?
</div>
<div className='text-center'>
<Button className='mt-4' variant='primary' onClick={() => handlePayBounty()}>
<Button className='mt-4' variant='primary' onClick={() => handlePayBounty(onClose)}>
pay <small>{abbrNum(root.bounty)} sats</small>
</Button>
</div>
</ModalButton>
</>
))
}}
>
pay bounty
</div>
</ActionTooltip>
)
}

View File

@ -4,7 +4,6 @@ import { gql, useApolloClient, useMutation } from '@apollo/client'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { MAX_POLL_NUM_CHOICES } from '../lib/constants'
import TextareaAutosize from 'react-textarea-autosize'
import FeeButton, { EditFeeButton } from './fee-button'
import Delete from './delete'
import { Button } from 'react-bootstrap'
@ -74,7 +73,6 @@ export function PollForm ({ item, sub, editThreshold, children }) {
topLevel
label={<>text <small className='text-muted ml-2'>optional</small></>}
name='text'
as={TextareaAutosize}
minRows={2}
/>
<VariableInput

View File

@ -17,7 +17,11 @@ export function usePrice () {
export function PriceProvider ({ price, children }) {
const me = useMe()
const fiatCurrency = me?.fiatCurrency
const { data } = useQuery(PRICE, { variables: { fiatCurrency }, pollInterval: 30000, fetchPolicy: 'cache-and-network' })
const { data } = useQuery(PRICE, {
variables: { fiatCurrency },
pollInterval: 30000,
nextFetchPolicy: 'cache-and-network'
})
const contextValue = {
price: data?.price || price,

View File

@ -1,14 +1,12 @@
import { ITEM_TYPES } from '../lib/constants'
import { Form, Select } from './form'
import { useRouter } from 'next/router'
export default function RecentHeader ({ type, sub }) {
const router = useRouter()
const prefix = sub?.name ? `/~${sub.name}` : ''
const prefix = sub ? `/~${sub}` : ''
const items = ['posts', 'bounties', 'comments', 'links', 'discussions', 'polls']
if (!sub?.name) {
items.push('bios')
}
const items = ITEM_TYPES(sub)
return (
<Form

View File

@ -1,37 +1,23 @@
import { useQuery } from '@apollo/client'
import Link from 'next/link'
import { RELATED_ITEMS } from '../fragments/items'
import AccordianItem from './accordian-item'
import Item, { ItemSkeleton } from './item'
import styles from './items.module.css'
import Items from './items'
import { NavigateFooter } from './more-footer'
const LIMIT = 5
export default function Related ({ title, itemId }) {
const emptyItems = new Array(5).fill(null)
const { data, loading } = useQuery(RELATED_ITEMS, {
fetchPolicy: 'cache-first',
variables: { title, id: itemId, limit: 5 }
})
let items, cursor
if (data) {
({ related: { items, cursor } } = data)
}
const variables = { title, id: itemId, limit: LIMIT }
return (
<AccordianItem
header={<div className='font-weight-bold'>related</div>}
body={
<>
<div className={styles.grid}>
{loading
? emptyItems.map((_, i) => <ItemSkeleton key={i} />)
: (items?.length
? items.map(item => <Item key={item.id} item={item} />)
: <div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>EMPTY</div>
)}
</div>
{cursor && itemId && <Link href={`/items/${itemId}/related`} passHref><a className='text-reset text-muted font-weight-bold'>view all related</a></Link>}
</>
<Items
query={RELATED_ITEMS}
variables={variables}
destructureData={data => data.related}
Footer={props => <NavigateFooter {...props} href={`/items/${itemId}/related`} text='view all related items' />}
/>
}
/>
)

View File

@ -3,7 +3,6 @@ import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css'
import { COMMENTS } from '../fragments/comments'
import { useMe } from './me'
import TextareaAutosize from 'react-textarea-autosize'
import { useEffect, useState, useRef } from 'react'
import Link from 'next/link'
import FeeButton from './fee-button'
@ -13,8 +12,8 @@ import Info from './info'
export function ReplyOnAnotherPage ({ parentId }) {
return (
<Link href={`/items/${parentId}`}>
<a className={`${styles.replyButtons} text-muted`}>reply on another page</a>
<Link href={`/items/${parentId}`} className={`${styles.replyButtons} text-muted`}>
reply on another page
</Link>
)
}
@ -110,7 +109,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
{/* HACK if we need more items, we should probably do a comment toolbar */}
{children}
</div>)}
<div className={reply ? `${styles.reply}` : 'd-none'}>
{reply &&
<div className={styles.reply}>
<Form
initial={{
text: ''
@ -128,7 +128,6 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
>
<MarkdownInput
name='text'
as={TextareaAutosize}
minRows={6}
autoFocus={!replyOpen}
required
@ -144,7 +143,7 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
/>
</div>}
</Form>
</div>
</div>}
</div>
)
}

View File

@ -1,51 +0,0 @@
import { useQuery } from '@apollo/client'
import { ItemSkeleton } from './item'
import styles from './items.module.css'
import { ITEM_SEARCH } from '../fragments/items'
import MoreFooter from './more-footer'
import { Fragment } from 'react'
import { CommentFlat } from './comment'
import ItemFull from './item-full'
export default function SearchItems ({ variables, items, pins, cursor }) {
const { data, fetchMore } = useQuery(ITEM_SEARCH, { variables })
if (!data && !items) {
return <ItemsSkeleton />
}
if (data) {
({ search: { items, cursor } } = data)
}
return (
<>
<div className={styles.grid}>
{items.map((item, i) => (
<Fragment key={item.id}>
{item.parentId
? <><div /><CommentFlat item={item} noReply includeParent /></>
: <><div /><div className={item.text ? 'pb-3' : ''}><ItemFull item={item} noReply /></div></>}
</Fragment>
))}
</div>
<MoreFooter
cursor={cursor} fetchMore={fetchMore}
noMoreText='EMPTY'
Skeleton={() => <ItemsSkeleton />}
/>
</>
)
}
function ItemsSkeleton () {
const items = new Array(21).fill(null)
return (
<div className={styles.grid}>
{items.map((_, i) => (
<ItemSkeleton key={i} />
))}
</div>
)
}

View File

@ -34,6 +34,9 @@ export default function Search ({ sub }) {
await router.push({
pathname: '/stackers/search',
query: { q, what: 'stackers' }
}, {
pathname: '/stackers/search',
query: { q }
})
return
}
@ -50,6 +53,7 @@ export default function Search ({ sub }) {
const showSearch = atBottom || searching || router.query.q
const filter = sub !== 'jobs'
const what = router.pathname.startsWith('/stackers') ? 'stackers' : router.query.what
return (
<>
<div className={`${styles.searchSection} ${showSearch ? styles.solid : styles.hidden}`}>
@ -60,7 +64,7 @@ export default function Search ({ sub }) {
className={styles.formActive}
initial={{
q: router.query.q || '',
what: router.query.what || '',
what: what || '',
sort: router.query.sort || '',
when: router.query.when || ''
}}
@ -75,7 +79,7 @@ export default function Search ({ sub }) {
size='sm'
items={['all', 'posts', 'comments', 'stackers']}
/>
{router.query.what !== 'stackers' &&
{what !== 'stackers' &&
<>
by
<Select

View File

@ -68,7 +68,7 @@ export default function Seo ({ sub, item, user }) {
}
}
if (user) {
desc = `@${user.name} has [${user.stacked} stacked, ${user.nitems} posts, ${user.ncomments} comments]`
desc = `@${user.name} has [${user.stacked} stacked, ${user.nposts} posts, ${user.ncomments} comments]`
}
return (

View File

@ -2,8 +2,9 @@ import { Alert } from 'react-bootstrap'
import YouTube from '../svgs/youtube-line.svg'
import { useEffect, useState } from 'react'
import { gql, useQuery } from '@apollo/client'
import { dayPivot } from '../lib/time'
export default function Snl () {
export default function Snl ({ ignorePreference }) {
const [show, setShow] = useState()
const { data } = useQuery(gql`{ snl }`, {
fetchPolicy: 'cache-and-network'
@ -11,14 +12,12 @@ export default function Snl () {
useEffect(() => {
const dismissed = localStorage.getItem('snl')
if (dismissed && dismissed > new Date(dismissed) < new Date(new Date().setDate(new Date().getDate() - 6))) {
if (!ignorePreference && dismissed && dismissed > new Date(dismissed) < dayPivot(new Date(), -6)) {
return
}
if (data?.snl) {
setShow(true)
}
}, [data])
setShow(data?.snl)
}, [data, ignorePreference])
if (!show) return null

View File

@ -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 SubscribeDropdownItem ({ item: { id, meSubscription } }) {

View File

@ -40,7 +40,7 @@ export default function Toc ({ text }) {
style={{
marginLeft: `${(v.depth - 1) * 5}px`
}}
key={v.slug} href={`#${v.slug}`}
href={`#${v.slug}`} key={v.slug}
>{v.heading}
</Dropdown.Item>
)

View File

@ -8,7 +8,7 @@ import sub from '../lib/remark-sub'
import remarkDirective from 'remark-directive'
import { visit } from 'unist-util-visit'
import reactStringReplace from 'react-string-replace'
import React, { useRef, useEffect, useState } from 'react'
import React, { useRef, useEffect, useState, memo } from 'react'
import GithubSlugger from 'github-slugger'
import LinkIcon from '../svgs/link.svg'
import Thumb from '../svgs/thumb-up-fill.svg'
@ -16,6 +16,7 @@ import { toString } from 'mdast-util-to-string'
import copy from 'clipboard-copy'
import { IMGPROXY_URL_REGEXP, IMG_URL_REGEXP } from '../lib/url'
import { extractUrls } from '../lib/md'
import FileMissing from '../svgs/file-warning-line.svg'
function myRemarkPlugin () {
return (tree) => {
@ -41,7 +42,7 @@ function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props
const Icon = copied ? Thumb : LinkIcon
return (
<div className={styles.heading}>
<span className={styles.heading}>
{React.createElement(h, { id, ...props }, children)}
{!noFragments && topLevel &&
<a className={`${styles.headingLink} ${copied ? styles.copied : ''}`} href={`#${id}`}>
@ -58,7 +59,7 @@ function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props
className='fill-grey'
/>
</a>}
</div>
</span>
)
}
@ -68,7 +69,8 @@ const CACHE_STATES = {
IS_ERROR: 'IS_ERROR'
}
export default function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, children }) {
// this is one of the slowest components to render
export default memo(function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, children }) {
// all the reactStringReplace calls are to facilitate search highlighting
const slugger = new GithubSlugger()
onlyImgProxy = onlyImgProxy ?? true
@ -121,9 +123,10 @@ export default function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, c
h5: (props) => HeadingWrapper({ h: topLevel ? 'h5' : 'h6', ...props }),
h6: (props) => HeadingWrapper({ h: 'h6', ...props }),
table: ({ node, ...props }) =>
<div className='table-responsive'>
<span className='table-responsive'>
<table className='table table-bordered table-sm' {...props} />
</div>,
</span>,
p: ({ children, ...props }) => <div className={styles.p} {...props}>{children}</div>,
code ({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline
@ -179,13 +182,10 @@ export default function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, c
</ReactMarkdown>
</div>
)
}
})
export function ZoomableImage ({ src, topLevel, ...props }) {
if (!src) {
return null
}
const [err, setErr] = useState()
const defaultMediaStyle = {
maxHeight: topLevel ? '75vh' : '25vh',
cursor: 'zoom-in'
@ -195,9 +195,25 @@ export function ZoomableImage ({ src, topLevel, ...props }) {
const [mediaStyle, setMediaStyle] = useState(defaultMediaStyle)
useEffect(() => {
setMediaStyle(defaultMediaStyle)
setErr(null)
}, [src])
const handleClick = () => {
if (!src) return null
if (err) {
return (
<span className='d-flex align-items-center text-warning font-weight-bold pb-1'>
<FileMissing width={18} height={18} className='fill-warning mr-1' />
broken image <small>stacker probably used an unreliable host</small>
</span>
)
}
return (
<img
className={topLevel ? styles.topLevel : undefined}
style={mediaStyle}
src={src}
onClick={() => {
if (mediaStyle.cursor === 'zoom-in') {
setMediaStyle({
width: '100%',
@ -206,14 +222,8 @@ export function ZoomableImage ({ src, topLevel, ...props }) {
} else {
setMediaStyle(defaultMediaStyle)
}
}
return (
<img
className={topLevel ? styles.topLevel : undefined}
style={mediaStyle}
src={src}
onClick={handleClick}
}}
onError={() => setErr(true)}
{...props}
/>
)

View File

@ -15,6 +15,7 @@
position: relative;
margin-left: -22px;
padding-left: 22px;
display: block;
}
.headingLink {
@ -23,6 +24,7 @@
left: 0px;
top: 0px;
height: 100%;
width: 44px;
}
.headingLink.copied {
@ -41,7 +43,7 @@
border-top: 1px solid var(--theme-clickToContextColor);
}
.text p {
.text .p {
margin-bottom: .5rem;
white-space: pre-wrap;
word-break: break-word;

View File

@ -1,8 +1,6 @@
import { useRouter } from 'next/router'
import { Form, Select } from './form'
const USER_SORTS = ['stacked', 'spent', 'comments', 'posts', 'referrals']
const ITEM_SORTS = ['votes', 'comments', 'sats']
import { ITEM_SORTS, USER_SORTS, WHENS } from '../lib/constants'
export default function TopHeader ({ sub, cat }) {
const router = useRouter()
@ -19,11 +17,11 @@ export default function TopHeader ({ sub, cat }) {
const prefix = sub ? `/~${sub}` : ''
if (typeof query.sort !== 'undefined') {
if (query.sort === '' ||
(what === 'stackers' && !USER_SORTS.includes(query.sort)) ||
(what !== 'stackers' && !ITEM_SORTS.includes(query.sort))) {
delete query.sort
if (typeof query.by !== 'undefined') {
if (query.by === '' ||
(what === 'stackers' && !USER_SORTS.includes(query.by)) ||
(what !== 'stackers' && !ITEM_SORTS.includes(query.by))) {
delete query.by
}
}
@ -39,7 +37,7 @@ export default function TopHeader ({ sub, cat }) {
className='mr-auto'
initial={{
what: cat,
sort: router.query.sort || '',
by: router.query.by || '',
when: router.query.when || ''
}}
onSubmit={top}
@ -58,8 +56,8 @@ export default function TopHeader ({ sub, cat }) {
by
<Select
groupClassName='mx-2 mb-0'
onChange={(formik, e) => top({ ...formik?.values, sort: e.target.value })}
name='sort'
onChange={(formik, e) => top({ ...formik?.values, by: e.target.value })}
name='by'
size='sm'
items={cat === 'stackers' ? USER_SORTS : ITEM_SORTS}
/>
@ -69,7 +67,7 @@ export default function TopHeader ({ sub, cat }) {
onChange={(formik, e) => top({ ...formik?.values, when: e.target.value })}
name='when'
size='sm'
items={['day', 'week', 'month', 'year', 'forever']}
items={WHENS}
/>
</>}

View File

@ -6,7 +6,7 @@ import ActionTooltip from './action-tooltip'
import ItemAct from './item-act'
import { useMe } from './me'
import Rainbow from '../lib/rainbow'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import LongPressable from 'react-longpressable'
import { Overlay, Popover } from 'react-bootstrap'
import { useShowModal } from './modal'
@ -78,7 +78,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}`
)
const setVoteShow = (yes) => {
const setVoteShow = useCallback((yes) => {
if (!me) return
// if they haven't seen the walkthrough and they have sats
@ -90,9 +90,9 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
_setVoteShow(false)
setWalkthrough({ variables: { upvotePopover: true } })
}
}
}, [me, voteShow, setWalkthrough])
const setTipShow = (yes) => {
const setTipShow = useCallback((yes) => {
if (!me) return
// if we want to show it, yet we still haven't shown
@ -105,7 +105,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
_setTipShow(false)
setWalkthrough({ variables: { tipPopover: true } })
}
}
}, [me, tipShow, setWalkthrough])
const [act] = useMutation(
gql`
@ -161,18 +161,20 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}
if (pendingSats > 0) {
timerRef.current = setTimeout(async (pendingSats) => {
timerRef.current = setTimeout(async (sats) => {
try {
timerRef.current && setPendingSats(0)
await act({
variables: { id: item.id, sats: pendingSats },
variables: { id: item.id, sats },
optimisticResponse: {
act: {
sats: pendingSats
sats
}
}
})
} catch (error) {
if (!timerRef.current) return
if (error.toString().includes('insufficient funds')) {
showModal(onClose => {
return <FundError onClose={onClose} />
@ -181,14 +183,14 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}
throw new Error({ message: error.toString() })
}
}, 1000, pendingSats)
}, 500, pendingSats)
}
return () => {
return async () => {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [item, pendingSats, act, setPendingSats, showModal])
}, [pendingSats, act, item, showModal, setPendingSats])
const disabled = useMemo(() => {
return item?.mine || (me && Number(me.id) === item?.fwdUserId) || item?.deletedAt
@ -213,7 +215,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
return (
<LightningConsumer>
{({ strike }) =>
{(strike) =>
<div ref={ref} className='upvoteParent'>
<LongPressable
onLongPress={

View File

@ -1,5 +1,6 @@
import { useRouter } from 'next/router'
import { Form, Select } from './form'
import { WHENS } from '../lib/constants'
export function UsageHeader () {
const router = useRouter()
@ -17,7 +18,7 @@ export function UsageHeader () {
className='w-auto'
name='when'
size='sm'
items={['day', 'week', 'month', 'year', 'forever']}
items={WHENS}
onChange={(formik, e) => router.push(`/stackers/${e.target.value}`)}
/>
</div>

View File

@ -10,20 +10,38 @@ import { useMe } from './me'
import { NAME_MUTATION } from '../fragments/users'
import QRCode from 'qrcode.react'
import LightningIcon from '../svgs/bolt.svg'
import ModalButton from './modal-button'
import { encodeLNUrl } from '../lib/lnurl'
import Avatar from './avatar'
import CowboyHat from './cowboy-hat'
import { userSchema } from '../lib/validate'
import { useShowModal } from './modal'
export default function UserHeader ({ user }) {
const [editting, setEditting] = useState(false)
const me = useMe()
const router = useRouter()
const client = useApolloClient()
const schema = userSchema(client)
const [setName] = useMutation(NAME_MUTATION)
return (
<>
<HeaderHeader user={user} />
<Nav
className={styles.nav}
activeKey={!!router.asPath.split('/')[2]}
>
<Nav.Item>
<Link href={'/' + user.name} passHref legacyBehavior>
<Nav.Link eventKey={false}>bio</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href={'/' + user.name + '/all'} passHref legacyBehavior>
<Nav.Link eventKey>{user.nitems} items</Nav.Link>
</Link>
</Nav.Item>
</Nav>
</>
)
}
function HeaderPhoto ({ user, isMe }) {
const [setPhoto] = useMutation(
gql`
mutation setPhoto($photoId: ID!) {
@ -42,14 +60,7 @@ export default function UserHeader ({ user }) {
}
)
const isMe = me?.name === user.name
const Satistics = () => <div className={`mb-2 ml-0 ml-sm-1 ${styles.username} text-success`}>{isMe ? `${user.sats} sats \\ ` : ''}{user.stacked} stacked</div>
const lnurlp = encodeLNUrl(new URL(`https://stacker.news/.well-known/lnurlp/${user.name}`))
return (
<>
<div className='d-flex mt-2 flex-wrap flex-column flex-sm-row'>
<div className='position-relative align-self-start' style={{ width: 'fit-content' }}>
<Image
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='135' height='135'
@ -64,9 +75,27 @@ export default function UserHeader ({ user }) {
}}
/>}
</div>
<div className='ml-0 ml-sm-3 mt-3 mt-sm-0 justify-content-center align-self-sm-center'>
{editting
? (
)
}
function NymEdit ({ user, setEditting }) {
const router = useRouter()
const [setName] = useMutation(NAME_MUTATION, {
update (cache, { data: { setName } }) {
cache.modify({
id: `User:${user.id}`,
fields: {
name () {
return setName
}
}
})
}
})
const client = useApolloClient()
const schema = userSchema(client)
return (
<Form
schema={schema}
initial={{
@ -83,25 +112,13 @@ export default function UserHeader ({ user }) {
throw new Error({ message: error.toString() })
}
setEditting(false)
// navigate to new name
const { nodata, ...query } = router.query
router.replace({
pathname: router.pathname,
query: { ...query, name }
})
client.writeFragment({
id: `User:${user.id}`,
fragment: gql`
fragment CurUser on User {
name
}
`,
data: {
name
}
})
setEditting(false)
}, undefined, { shallow: true })
}}
>
<div className='d-flex align-items-center mb-2'>
@ -116,64 +133,66 @@ export default function UserHeader ({ user }) {
</div>
</Form>
)
: (
}
function NymView ({ user, isMe, setEditting }) {
return (
<div className='d-flex align-items-center mb-2'>
<div className={styles.username}>@{user.name}<CowboyHat className='' user={user} badge /></div>
{isMe &&
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
</div>
)}
)
}
function HeaderNym ({ user, isMe }) {
const [editting, setEditting] = useState(false)
return editting
? <NymEdit user={user} setEditting={setEditting} />
: <NymView user={user} isMe={isMe} setEditting={setEditting} />
}
function HeaderHeader ({ user }) {
const me = useMe()
const showModal = useShowModal()
const isMe = me?.name === user.name
const Satistics = () => <div className={`mb-2 ml-0 ml-sm-1 ${styles.username} text-success`}>{user.stacked} stacked</div>
const lnurlp = encodeLNUrl(new URL(`https://stacker.news/.well-known/lnurlp/${user.name}`))
return (
<div className='d-flex mt-2 flex-wrap flex-column flex-sm-row'>
<HeaderPhoto user={user} isMe={isMe} />
<div className='ml-0 ml-sm-3 mt-3 mt-sm-0 justify-content-center align-self-sm-center'>
<HeaderNym user={user} isMe={isMe} />
<Satistics user={user} />
<ModalButton
clicker={
<Button className='font-weight-bold ml-0'>
<Button
className='font-weight-bold ml-0' onClick={() => {
showModal(({ onClose }) => (
<>
<a className='d-flex m-auto p-3' style={{ background: 'white', width: 'fit-content' }} href={`lightning:${lnurlp}`}>
<QRCode className='d-flex m-auto' value={lnurlp} renderAs='svg' size={300} />
</a>
<div className='text-center font-weight-bold text-muted mt-3'>click or scan</div>
</>
))
}}
>
<LightningIcon
width={20}
height={20}
className='mr-1'
/>{user.name}@stacker.news
</Button>
}
>
<a className='d-flex m-auto p-3' style={{ background: 'white', width: 'fit-content' }} href={`lightning:${lnurlp}`}>
<QRCode className='d-flex m-auto' value={lnurlp} renderAs='svg' size={300} />
</a>
<div className='text-center font-weight-bold text-muted mt-3'>click or scan</div>
</ModalButton>
<div className='d-flex flex-column mt-1 ml-0'>
<small className='text-muted d-flex-inline'>stacking since: {user.since
? <Link href={`/items/${user.since}`} passHref><a className='ml-1'>#{user.since}</a></Link>
? <Link href={`/items/${user.since}`} className='ml-1'>#{user.since}</Link>
: <span>never</span>}
</small>
<small className='text-muted d-flex-inline'>longest cowboy streak: {user.maxStreak !== null ? user.maxStreak : 'none'}</small>
</div>
</div>
</div>
<Nav
className={styles.nav}
activeKey={router.asPath.split('?')[0]}
>
<Nav.Item>
<Link href={'/' + user.name} passHref>
<Nav.Link>bio</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href={'/' + user.name + '/posts'} passHref>
<Nav.Link>{user.nitems} posts</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href={'/' + user.name + '/comments'} passHref>
<Nav.Link>{user.ncomments} comments</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href={'/' + user.name + '/bookmarks'} passHref>
<Nav.Link>{user.nbookmarks} bookmarks</Nav.Link>
</Link>
</Nav.Item>
</Nav>
</>
)
}

View File

@ -4,16 +4,21 @@
}
.nav {
margin-top: 1rem;
justify-content: space-between;
margin: 1rem 0;
justify-content: start;
font-size: 110%;
}
.nav div:first-child a {
.nav :global .nav-link {
padding-left: 0;
}
.nav div:last-child a {
padding-right: 0;
.nav :global .nav-item:not(:first-child) {
margin-left: 1rem;
}
.nav :global .active {
border-bottom: 2px solid var(--primary);
}
.userimg {

View File

@ -4,18 +4,20 @@ import { abbrNum } from '../lib/format'
import CowboyHat from './cowboy-hat'
import styles from './item.module.css'
import userStyles from './user-header.module.css'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@apollo/client'
import MoreFooter from './more-footer'
// all of this nonsense is to show the stat we are sorting by first
const Stacked = ({ user }) => (<span>{abbrNum(user.stacked)} stacked</span>)
const Spent = ({ user }) => (<span>{abbrNum(user.spent)} spent</span>)
const Posts = ({ user }) => (
<Link href={`/${user.name}/posts`} passHref>
<a className='text-reset'>{abbrNum(user.nitems)} posts</a>
<Link href={`/${user.name}/posts`} className='text-reset'>
{abbrNum(user.nposts)} posts
</Link>)
const Comments = ({ user }) => (
<Link href={`/${user.name}/comments`} passHref>
<a className='text-reset'>{abbrNum(user.ncomments)} comments</a>
<Link href={`/${user.name}/comments`} className='text-reset'>
{abbrNum(user.ncomments)} comments
</Link>)
const Referrals = ({ user }) => (<span>{abbrNum(user.referrals)} referrals</span>)
const Seperator = () => (<span> \ </span>)
@ -33,33 +35,44 @@ function seperate (arr, seperator) {
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x])
}
export default function UserList ({ users, sort }) {
export default function UserList ({ ssrData, query, variables, destructureData }) {
const { data, fetchMore } = useQuery(query, { variables })
const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator))
useEffect(() => {
if (sort) {
if (variables.by) {
// shift the stat we are sorting by to the front
const comps = [...STAT_COMPONENTS]
setStatComps(seperate([...comps.splice(STAT_POS[sort], 1), ...comps], Seperator))
setStatComps(seperate([...comps.splice(STAT_POS[variables.by], 1), ...comps], Seperator))
}
}, [variables.by])
const { users, cursor } = useMemo(() => {
if (!data && !ssrData) return {}
if (destructureData) {
return destructureData(data || ssrData)
} else {
return data || ssrData
}
}, [data, ssrData])
if (!ssrData && !data) {
return <UsersSkeleton />
}
}, [sort])
return (
<> {users.map(user => (
<>
{users?.map(user => (
<div className={`${styles.item} mb-2`} key={user.name}>
<Link href={`/${user.name}`} passHref>
<a>
<Link href={`/${user.name}`}>
<Image
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
className={`${userStyles.userimg} mr-2`}
/>
</a>
</Link>
<div className={styles.hunk}>
<Link href={`/${user.name}`} passHref>
<a className={`${styles.title} d-inline-flex align-items-center text-reset`}>
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
@{user.name}<CowboyHat className='ml-1 fill-grey' height={14} width={14} user={user} />
</a>
</Link>
<div className={styles.other}>
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
@ -67,6 +80,7 @@ export default function UserList ({ users, sort }) {
</div>
</div>
))}
<MoreFooter cursor={cursor} count={users?.length} fetchMore={fetchMore} Skeleton={UsersSkeleton} noMoreText='NO MORE' />
</>
)
}
@ -78,7 +92,8 @@ export function UsersSkeleton () {
<div>{users.map((_, i) => (
<div className={`${styles.item} ${styles.skeleton} mb-2`} key={i}>
<Image
src='/clouds.jpeg' width='32' height='32'
src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/clouds.jpeg`}
width='32' height='32'
className={`${userStyles.userimg} clouds mr-2`}
/>
<div className={styles.hunk}>

View File

@ -31,14 +31,9 @@ export const COMMENT_FIELDS = gql`
}
`
export const MORE_FLAT_COMMENTS = gql`
${COMMENT_FIELDS}
query MoreFlatComments($sub: String, $sort: String!, $cursor: String, $name: String, $within: String) {
moreFlatComments(sub: $sub, sort: $sort, cursor: $cursor, name: $name, within: $within) {
cursor
comments {
...CommentFields
export const COMMENTS_ITEM_EXT_FIELDS = gql`
fragment CommentItemExtFields on Item {
text
root {
id
title
@ -52,36 +47,7 @@ export const MORE_FLAT_COMMENTS = gql`
id
}
}
}
}
}
`
export const TOP_COMMENTS = gql`
${COMMENT_FIELDS}
query topComments($sort: String, $cursor: String, $when: String = "day") {
topComments(sort: $sort, cursor: $cursor, when: $when) {
cursor
comments {
...CommentFields
root {
id
title
bounty
bountyPaidTo
subName
user {
name
streak
hideCowboyHat
id
}
}
}
}
}
`
}`
export const COMMENTS = gql`
${COMMENT_FIELDS}

View File

@ -91,72 +91,6 @@ export const ITEM_OTS = gql`
}
}`
export const ITEMS = gql`
${ITEM_FIELDS}
query items($sub: String, $sort: String, $type: String, $cursor: String, $name: String, $within: String) {
items(sub: $sub, sort: $sort, type: $type, cursor: $cursor, name: $name, within: $within) {
cursor
items {
...ItemFields
},
pins {
...ItemFields
}
}
}`
export const TOP_ITEMS = gql`
${ITEM_FIELDS}
query topItems($sort: String, $cursor: String, $when: String) {
topItems(sort: $sort, cursor: $cursor, when: $when) {
cursor
items {
...ItemFields
},
pins {
...ItemFields
}
}
}`
export const OUTLAWED_ITEMS = gql`
${ITEM_FULL_FIELDS}
query outlawedItems($cursor: String) {
outlawedItems(cursor: $cursor) {
cursor
items {
...ItemFullFields
}
}
}`
export const BORDERLAND_ITEMS = gql`
${ITEM_FULL_FIELDS}
query borderlandItems($cursor: String) {
borderlandItems(cursor: $cursor) {
cursor
items {
...ItemFullFields
}
}
}`
export const FREEBIE_ITEMS = gql`
${ITEM_FULL_FIELDS}
query freebieItems($cursor: String) {
freebieItems(cursor: $cursor) {
cursor
items {
...ItemFullFields
}
}
}`
export const POLL_FIELDS = gql`
fragment PollFields on Item {
poll {
@ -217,31 +151,6 @@ export const ITEM_WITH_COMMENTS = gql`
}
}`
export const BOUNTY_ITEMS_BY_USER_NAME = gql`
${ITEM_FIELDS}
query getBountiesByUserName($name: String!, $cursor: String, $limit: Int) {
getBountiesByUserName(name: $name, cursor: $cursor, limit: $limit) {
cursor
items {
...ItemFields
}
}
}`
export const ITEM_SEARCH = gql`
${ITEM_FULL_FIELDS}
query Search($q: String, $cursor: String, $sort: String, $what: String, $when: String) {
search(q: $q, cursor: $cursor, sort: $sort, what: $what, when: $when) {
cursor
items {
...ItemFullFields
searchTitle
searchText
}
}
}
`
export const RELATED_ITEMS = gql`
${ITEM_FIELDS}
query Related($title: String, $id: ID, $cursor: String, $limit: Int) {

View File

@ -2,6 +2,8 @@ import { gql } from '@apollo/client'
import { ITEM_FULL_FIELDS } from './items'
import { INVITE_FIELDS } from './invites'
export const HAS_NOTIFICATIONS = gql`{ hasNewNotes }`
export const NOTIFICATIONS = gql`
${ITEM_FULL_FIELDS}
${INVITE_FIELDS}
@ -13,6 +15,7 @@ export const NOTIFICATIONS = gql`
notifications {
__typename
... on Mention {
id
sortTime
mention
item {
@ -21,6 +24,7 @@ export const NOTIFICATIONS = gql`
}
}
... on Votification {
id
sortTime
earnedSats
item {
@ -34,6 +38,7 @@ export const NOTIFICATIONS = gql`
days
}
... on Earn {
id
sortTime
earnedSats
sources {
@ -44,9 +49,11 @@ export const NOTIFICATIONS = gql`
}
}
... on Referral {
id
sortTime
}
... on Reply {
id
sortTime
item {
...ItemFullFields
@ -54,18 +61,21 @@ export const NOTIFICATIONS = gql`
}
}
... on Invitification {
id
sortTime
invite {
...InviteFields
}
}
... on JobChanged {
id
sortTime
item {
...ItemFields
}
}
... on InvoicePaid {
id
sortTime
earnedSats
invoice {

View File

@ -1,6 +1,6 @@
import { gql } from '@apollo/client'
import { ITEM_FIELDS } from './items'
import { COMMENT_FIELDS } from './comments'
import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items'
import { COMMENTS_ITEM_EXT_FIELDS } from './comments'
export const SUB_FIELDS = gql`
fragment SubFields on Sub {
@ -13,7 +13,7 @@ export const SUB_FIELDS = gql`
export const SUB = gql`
${SUB_FIELDS}
query Sub($sub: String!) {
query Sub($sub: String) {
sub(name: $sub) {
...SubFields
}
@ -22,119 +22,43 @@ export const SUB = gql`
export const SUB_ITEMS = gql`
${SUB_FIELDS}
${ITEM_FIELDS}
query SubItems($sub: String!, $sort: String, $type: String) {
${COMMENTS_ITEM_EXT_FIELDS}
query SubItems($sub: String, $sort: String, $cursor: String, $type: String, $name: String, $when: String, $by: String, $limit: Int, $includeComments: Boolean = false) {
sub(name: $sub) {
...SubFields
}
items(sub: $sub, sort: $sort, type: $type) {
items(sub: $sub, sort: $sort, cursor: $cursor, type: $type, name: $name, when: $when, by: $by, limit: $limit) {
cursor
items {
...ItemFields
...CommentItemExtFields @include(if: $includeComments)
position
},
pins {
...ItemFields
...CommentItemExtFields @include(if: $includeComments)
position
}
}
}
`
export const SUB_TOP_ITEMS = gql`
${SUB_FIELDS}
${ITEM_FIELDS}
query SubTopItems($sub: String!, $sort: String, $cursor: String, $when: String) {
sub(name: $sub) {
...SubFields
}
topItems(sub: $sub, sort: $sort, cursor: $cursor, when: $when) {
cursor
items {
...ItemFields
},
pins {
...ItemFields
}
}
}`
export const SUB_TOP_COMMENTS = gql`
${SUB_FIELDS}
${COMMENT_FIELDS}
query SubTopComments($sub: String!, $sort: String, $cursor: String, $when: String = "day") {
sub(name: $sub) {
...SubFields
}
topComments(sub: $sub, sort: $sort, cursor: $cursor, when: $when) {
cursor
comments {
...CommentFields
root {
id
title
bounty
bountyPaidTo
subName
user {
name
streak
hideCowboyHat
id
}
}
}
}
}
`
export const SUB_SEARCH = gql`
${SUB_FIELDS}
${ITEM_FIELDS}
query SubSearch($sub: String!, $q: String, $cursor: String) {
${ITEM_FULL_FIELDS}
query SubSearch($sub: String, $q: String, $cursor: String, $sort: String, $what: String, $when: String) {
sub(name: $sub) {
...SubFields
}
search(q: $q, cursor: $cursor) {
search(sub: $sub, q: $q, cursor: $cursor, sort: $sort, what: $what, when: $when) {
cursor
items {
...ItemFields
text
...ItemFullFields
searchTitle
searchText
}
}
}
`
export const SUB_FLAT_COMMENTS = gql`
${SUB_FIELDS}
${COMMENT_FIELDS}
query SubFlatComments($sub: String!, $sort: String!, $cursor: String) {
sub(name: $sub) {
...SubFields
}
moreFlatComments(sub: $sub, sort: $sort, cursor: $cursor) {
cursor
comments {
...CommentFields
root {
id
title
bounty
bountyPaidTo
subName
user {
name
streak
hideCowboyHat
id
}
}
}
}
}
`

View File

@ -1,6 +1,6 @@
import { gql } from '@apollo/client'
import { COMMENT_FIELDS } from './comments'
import { ITEM_FIELDS, ITEM_FULL_FIELDS, ITEM_WITH_COMMENTS } from './items'
import { COMMENTS_ITEM_EXT_FIELDS } from './comments'
import { ITEM_FIELDS, ITEM_WITH_COMMENTS } from './items'
export const ME = gql`
{
@ -116,35 +116,27 @@ gql`
stacked
spent
ncomments
nitems
nposts
referrals
}
}`
export const USER_FIELDS = gql`
${ITEM_FIELDS}
fragment UserFields on User {
id
createdAt
name
streak
maxStreak
hideCowboyHat
nitems
ncomments
nbookmarks
stacked
sats
since
photoId
bio {
...ItemFields
text
}
}`
export const TOP_USERS = gql`
query TopUsers($cursor: String, $when: String, $sort: String) {
topUsers(cursor: $cursor, when: $when, sort: $sort) {
query TopUsers($cursor: String, $when: String, $by: String) {
topUsers(cursor: $cursor, when: $when, by: $by) {
users {
name
streak
@ -153,7 +145,7 @@ export const TOP_USERS = gql`
stacked(when: $when)
spent(when: $when)
ncomments(when: $when)
nitems(when: $when)
nposts(when: $when)
referrals(when: $when)
}
cursor
@ -172,7 +164,7 @@ export const TOP_COWBOYS = gql`
stacked(when: "forever")
spent(when: "forever")
ncomments(when: "forever")
nitems(when: "forever")
nposts(when: "forever")
referrals(when: "forever")
}
cursor
@ -186,74 +178,25 @@ export const USER_FULL = gql`
query User($name: String!) {
user(name: $name) {
...UserFields
since
bio {
...ItemWithComments
}
}
}`
export const USER_WITH_COMMENTS = gql`
${USER_FIELDS}
${COMMENT_FIELDS}
query UserWithComments($name: String!) {
user(name: $name) {
...UserFields
since
}
moreFlatComments(sort: "user", name: $name) {
cursor
comments {
...CommentFields
root {
id
title
bounty
bountyPaidTo
subName
user {
name
streak
hideCowboyHat
id
}
}
}
}
}`
export const USER_WITH_BOOKMARKS = gql`
${USER_FIELDS}
${ITEM_FULL_FIELDS}
query UserWithBookmarks($name: String!, $cursor: String) {
user(name: $name) {
...UserFields
since
}
moreBookmarks(name: $name, cursor: $cursor) {
cursor
items {
...ItemFullFields
}
}
}
`
export const USER_WITH_POSTS = gql`
export const USER_WITH_ITEMS = gql`
${USER_FIELDS}
${ITEM_FIELDS}
query UserWithPosts($name: String!) {
${COMMENTS_ITEM_EXT_FIELDS}
query UserWithItems($name: String!, $sub: String, $cursor: String, $type: String, $when: String, $by: String, $limit: Int, $includeComments: Boolean = false) {
user(name: $name) {
...UserFields
since
}
items(sort: "user", name: $name) {
items(sub: $sub, sort: "user", cursor: $cursor, type: $type, name: $name, when: $when, by: $by, limit: $limit) {
cursor
items {
...ItemFields
}
pins {
...ItemFields
...CommentItemExtFields @include(if: $includeComments)
}
}
}`

View File

@ -1,461 +0,0 @@
import {
$applyNodeReplacement,
$getNodeByKey,
$getSelection,
$isNodeSelection,
$setSelection,
CLICK_COMMAND, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, createEditor, DecoratorNode,
DRAGSTART_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND, SELECTION_CHANGE_COMMAND
} from 'lexical'
import { useRef, Suspense, useEffect, useCallback } from 'react'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
const imageCache = new Set()
function useSuspenseImage (src) {
if (!imageCache.has(src)) {
throw new Promise((resolve) => {
const img = new Image()
img.src = src
img.onload = () => {
imageCache.add(src)
resolve(null)
}
})
}
}
function LazyImage ({
altText,
className,
imageRef,
src,
width,
height,
maxWidth
}) {
useSuspenseImage(src)
return (
<img
className={className || undefined}
src={src}
alt={altText}
ref={imageRef}
style={{
height,
maxHeight: '25vh',
// maxWidth,
// width,
display: 'block',
marginBottom: '.5rem',
marginTop: '.5rem',
borderRadius: '.4rem',
width: 'auto',
maxWidth: '100%'
}}
/>
)
}
function convertImageElement (domNode) {
if (domNode instanceof HTMLImageElement) {
const { alt: altText, src } = domNode
const node = $createImageNode({ altText, src })
return { node }
}
return null
}
export class ImageNode extends DecoratorNode {
__src;
__altText;
__width;
__height;
__maxWidth;
__showCaption;
__caption;
// Captions cannot yet be used within editor cells
__captionsEnabled;
static getType () {
return 'image'
}
static clone (node) {
return new ImageNode(
node.__src,
node.__altText,
node.__maxWidth,
node.__width,
node.__height,
node.__showCaption,
node.__caption,
node.__captionsEnabled,
node.__key
)
}
static importJSON (serializedNode) {
const { altText, height, width, maxWidth, caption, src, showCaption } =
serializedNode
const node = $createImageNode({
altText,
height,
maxWidth,
showCaption,
src,
width
})
const nestedEditor = node.__caption
const editorState = nestedEditor.parseEditorState(caption.editorState)
if (!editorState.isEmpty()) {
nestedEditor.setEditorState(editorState)
}
return node
}
exportDOM () {
const element = document.createElement('img')
element.setAttribute('src', this.__src)
element.setAttribute('alt', this.__altText)
return { element }
}
static importDOM () {
return {
img: (node) => ({
conversion: convertImageElement,
priority: 0
})
}
}
constructor (
src,
altText,
maxWidth,
width,
height,
showCaption,
caption,
captionsEnabled,
key
) {
super(key)
this.__src = src
this.__altText = altText
this.__maxWidth = maxWidth
this.__width = width || 'inherit'
this.__height = height || 'inherit'
this.__showCaption = showCaption || false
this.__caption = caption || createEditor()
this.__captionsEnabled = captionsEnabled || captionsEnabled === undefined
}
exportJSON () {
return {
altText: this.getAltText(),
caption: this.__caption.toJSON(),
height: this.__height === 'inherit' ? 0 : this.__height,
maxWidth: this.__maxWidth,
showCaption: this.__showCaption,
src: this.getSrc(),
type: 'image',
version: 1,
width: this.__width === 'inherit' ? 0 : this.__width
}
}
setWidthAndHeight (
width,
height
) {
const writable = this.getWritable()
writable.__width = width
writable.__height = height
}
setShowCaption (showCaption) {
const writable = this.getWritable()
writable.__showCaption = showCaption
}
// View
createDOM (config) {
const span = document.createElement('span')
const theme = config.theme
const className = theme.image
if (className !== undefined) {
span.className = className
}
return span
}
updateDOM () {
return false
}
getSrc () {
return this.__src
}
getAltText () {
return this.__altText
}
decorate () {
return (
<Suspense fallback={null}>
<ImageComponent
src={this.__src}
altText={this.__altText}
width={this.__width}
height={this.__height}
maxWidth={this.__maxWidth}
nodeKey={this.getKey()}
showCaption={this.__showCaption}
caption={this.__caption}
captionsEnabled={this.__captionsEnabled}
resizable
/>
</Suspense>
)
}
}
export function $createImageNode ({
altText,
height,
maxWidth = 500,
captionsEnabled,
src,
width,
showCaption,
caption,
key
}) {
return $applyNodeReplacement(
new ImageNode(
src,
altText,
maxWidth,
width,
height,
showCaption,
caption,
captionsEnabled,
key
)
)
}
export function $isImageNode (
node
) {
return node instanceof ImageNode
}
export default function ImageComponent ({
src,
altText,
nodeKey,
width,
height,
maxWidth,
resizable,
showCaption,
caption,
captionsEnabled
}) {
const imageRef = useRef(null)
const buttonRef = useRef(null)
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey)
const [editor] = useLexicalComposerContext()
// const [selection, setSelection] = useState(null)
const activeEditorRef = useRef(null)
const onDelete = useCallback(
(payload) => {
if (isSelected && $isNodeSelection($getSelection())) {
const event = payload
event.preventDefault()
const node = $getNodeByKey(nodeKey)
if ($isImageNode(node)) {
node.remove()
}
setSelected(false)
}
return false
},
[isSelected, nodeKey, setSelected]
)
const onEnter = useCallback(
(event) => {
const latestSelection = $getSelection()
const buttonElem = buttonRef.current
if (
isSelected &&
$isNodeSelection(latestSelection) &&
latestSelection.getNodes().length === 1
) {
if (showCaption) {
// Move focus into nested editor
$setSelection(null)
event.preventDefault()
caption.focus()
return true
} else if (
buttonElem !== null &&
buttonElem !== document.activeElement
) {
event.preventDefault()
buttonElem.focus()
return true
}
}
return false
},
[caption, isSelected, showCaption]
)
const onEscape = useCallback(
(event) => {
if (
activeEditorRef.current === caption ||
buttonRef.current === event.target
) {
$setSelection(null)
editor.update(() => {
setSelected(true)
const parentRootElement = editor.getRootElement()
if (parentRootElement !== null) {
parentRootElement.focus()
}
})
return true
}
return false
},
[caption, editor, setSelected]
)
useEffect(() => {
return mergeRegister(
// editor.registerUpdateListener(({ editorState }) => {
// setSelection(editorState.read(() => $getSelection()))
// }),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_, activeEditor) => {
activeEditorRef.current = activeEditor
return false
},
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
CLICK_COMMAND,
(payload) => {
const event = payload
if (event.target === imageRef.current) {
if (event.shiftKey) {
setSelected(!isSelected)
} else {
clearSelection()
setSelected(true)
}
return true
}
return false
},
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
DRAGSTART_COMMAND,
(payload) => {
const event = payload
if (event.target === imageRef.current) {
if (event.shiftKey) {
setSelected(!isSelected)
} else {
clearSelection()
setSelected(true)
}
return true
}
return false
},
COMMAND_PRIORITY_HIGH
),
editor.registerCommand(
DRAGSTART_COMMAND,
(event) => {
if (event.target === imageRef.current) {
// TODO This is just a temporary workaround for FF to behave like other browsers.
// Ideally, this handles drag & drop too (and all browsers).
event.preventDefault()
return true
}
return false
},
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
KEY_DELETE_COMMAND,
onDelete,
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
onDelete,
COMMAND_PRIORITY_LOW
),
editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_LOW),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_LOW
)
)
}, [
clearSelection,
editor,
isSelected,
nodeKey,
onDelete,
onEnter,
onEscape,
setSelected
])
// const draggable = isSelected && $isNodeSelection(selection)
// const isFocused = isSelected
return (
<Suspense fallback={null}>
<>
<div draggable>
<LazyImage
// className={
// isFocused
// ? `focused ${$isNodeSelection(selection) ? 'draggable' : ''}`
// : null
// }
src={src}
altText={altText}
imageRef={imageRef}
width={width}
height={height}
maxWidth={maxWidth}
/>
</div>
</>
</Suspense>
)
}

View File

@ -1,34 +0,0 @@
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'
const URL_MATCHER = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
const EMAIL_MATCHER = /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/
const MATCHERS = [
(text) => {
const match = URL_MATCHER.exec(text)
return (
match && {
index: match.index,
length: match[0].length,
text: match[0],
url: match[0]
}
)
},
(text) => {
const match = EMAIL_MATCHER.exec(text)
return (
match && {
index: match.index,
length: match[0].length,
text: match[0],
url: `mailto:${match[0]}`
}
)
}
]
export default function PlaygroundAutoLinkPlugin () {
return <AutoLinkPlugin matchers={MATCHERS} />
}

View File

@ -1,248 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'
import {
$createParagraphNode,
$createRangeSelection,
$getSelection,
$insertNodes,
$isRootOrShadowRoot,
$setSelection,
COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_HIGH,
COMMAND_PRIORITY_LOW,
createCommand,
DRAGOVER_COMMAND,
DRAGSTART_COMMAND,
DROP_COMMAND
} from 'lexical'
import { useEffect, useRef } from 'react'
import { ensureProtocol } from '../../lib/url'
import {
$createImageNode,
$isImageNode,
ImageNode
} from '../nodes/image'
import { Form, Input, SubmitButton } from '../../components/form'
import styles from '../styles.module.css'
import { urlSchema } from '../../lib/validate'
const getDOMSelection = (targetWindow) =>
typeof window !== 'undefined' ? (targetWindow || window).getSelection() : null
export const INSERT_IMAGE_COMMAND = createCommand('INSERT_IMAGE_COMMAND')
export function ImageInsertModal ({ onClose, editor }) {
const inputRef = useRef(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
return (
<Form
initial={{
url: '',
alt: ''
}}
schema={urlSchema}
onSubmit={async ({ alt, url }) => {
editor.dispatchCommand(INSERT_IMAGE_COMMAND, { src: ensureProtocol(url), altText: alt })
onClose()
}}
>
<Input
label='url'
name='url'
innerRef={inputRef}
required
autoFocus
/>
<Input
label={<>alt text <small className='text-muted ml-2'>optional</small></>}
name='alt'
/>
<div className='d-flex'>
<SubmitButton variant='success' className='ml-auto mt-1 px-4'>ok</SubmitButton>
</div>
</Form>
)
}
export default function ImageInsertPlugin ({
captionsEnabled
}) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([ImageNode])) {
throw new Error('ImagesPlugin: ImageNode not registered on editor')
}
return mergeRegister(
editor.registerCommand(
INSERT_IMAGE_COMMAND,
(payload) => {
const imageNode = $createImageNode(payload)
$insertNodes([imageNode])
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd()
}
return true
},
COMMAND_PRIORITY_EDITOR
),
editor.registerCommand(
DRAGSTART_COMMAND,
(event) => {
return onDragStart(event)
},
COMMAND_PRIORITY_HIGH
),
editor.registerCommand(
DRAGOVER_COMMAND,
(event) => {
return onDragover(event)
},
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
DROP_COMMAND,
(event) => {
return onDrop(event, editor)
},
COMMAND_PRIORITY_HIGH
)
)
}, [captionsEnabled, editor])
return null
}
const TRANSPARENT_IMAGE =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
const img = typeof window !== 'undefined' ? document.createElement('img') : undefined
if (img) {
img.src = TRANSPARENT_IMAGE
}
function onDragStart (event) {
const node = getImageNodeInSelection()
if (!node) {
return false
}
const dataTransfer = event.dataTransfer
if (!dataTransfer) {
return false
}
dataTransfer.setData('text/plain', '_')
img.src = node.getSrc()
dataTransfer.setDragImage(img, 0, 0)
dataTransfer.setData(
'application/x-lexical-drag',
JSON.stringify({
data: {
altText: node.__altText,
caption: node.__caption,
height: node.__height,
maxHeight: '25vh',
key: node.getKey(),
maxWidth: node.__maxWidth,
showCaption: node.__showCaption,
src: node.__src,
width: node.__width
},
type: 'image'
})
)
return true
}
function onDragover (event) {
const node = getImageNodeInSelection()
if (!node) {
return false
}
if (!canDropImage(event)) {
event.preventDefault()
}
return true
}
function onDrop (event, editor) {
const node = getImageNodeInSelection()
if (!node) {
return false
}
const data = getDragImageData(event)
if (!data) {
return false
}
event.preventDefault()
if (canDropImage(event)) {
const range = getDragSelection(event)
node.remove()
const rangeSelection = $createRangeSelection()
if (range !== null && range !== undefined) {
rangeSelection.applyDOMRange(range)
}
$setSelection(rangeSelection)
editor.dispatchCommand(INSERT_IMAGE_COMMAND, data)
}
return true
}
function getImageNodeInSelection () {
const selection = $getSelection()
const nodes = selection.getNodes()
const node = nodes[0]
return $isImageNode(node) ? node : null
}
function getDragImageData (event) {
const dragData = event.dataTransfer?.getData('application/x-lexical-drag')
if (!dragData) {
return null
}
const { type, data } = JSON.parse(dragData)
if (type !== 'image') {
return null
}
return data
}
function canDropImage (event) {
const target = event.target
return !!(
target &&
target instanceof HTMLElement &&
!target.closest('code, span.editor-image') &&
target.parentElement &&
target.parentElement.closest(`div.${styles.editorInput}`)
)
}
function getDragSelection (event) {
let range
const target = event.target
const targetWindow =
target == null
? null
: target.nodeType === 9
? target.defaultView
: target.ownerDocument.defaultView
const domSelection = getDOMSelection(targetWindow)
if (document.caretRangeFromPoint) {
range = document.caretRangeFromPoint(event.clientX, event.clientY)
} else if (event.rangeParent && domSelection !== null) {
domSelection.collapse(event.rangeParent, event.rangeOffset || 0)
range = domSelection.getRangeAt(0)
} else {
throw Error('Cannot get the selection when dragging')
}
return range
}

View File

@ -1,129 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $createTextNode, $getSelection, $insertNodes, $setSelection, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical'
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'
import { $createLinkNode, $isLinkNode } from '@lexical/link'
import { Modal } from 'react-bootstrap'
import React, { useState, useCallback, useContext, useRef, useEffect } from 'react'
import { Form, Input, SubmitButton } from '../../components/form'
import { ensureProtocol } from '../../lib/url'
import { getSelectedNode } from '../utils/selected-node'
import { namedUrlSchema } from '../../lib/validate'
export const INSERT_LINK_COMMAND = createCommand('INSERT_LINK_COMMAND')
export default function LinkInsertPlugin () {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return mergeRegister(
editor.registerCommand(
INSERT_LINK_COMMAND,
(payload) => {
const selection = $getSelection()
const node = getSelectedNode(selection)
const parent = node.getParent()
if ($isLinkNode(parent)) {
parent.remove()
} else if ($isLinkNode(node)) {
node.remove()
}
const textNode = $createTextNode(payload.text)
$insertNodes([textNode])
const linkNode = $createLinkNode(payload.url)
$wrapNodeInElement(textNode, () => linkNode)
$setSelection(textNode.select())
return true
},
COMMAND_PRIORITY_EDITOR
)
)
}, [editor])
return null
}
export const LinkInsertContext = React.createContext({
link: null,
setLink: () => {}
})
export function LinkInsertProvider ({ children }) {
const [link, setLink] = useState(null)
const contextValue = {
link,
setLink: useCallback(link => setLink(link), [])
}
return (
<LinkInsertContext.Provider value={contextValue}>
<LinkInsertModal />
{children}
</LinkInsertContext.Provider>
)
}
export function useLinkInsert () {
const { link, setLink } = useContext(LinkInsertContext)
return { link, setLink }
}
export function LinkInsertModal () {
const [editor] = useLexicalComposerContext()
const { link, setLink } = useLinkInsert()
const inputRef = useRef(null)
useEffect(() => {
if (link) {
inputRef.current?.focus()
}
}, [link])
return (
<Modal
show={!!link}
onHide={() => {
setLink(null)
setTimeout(() => editor.focus(), 100)
}}
>
<div
className='modal-close' onClick={() => {
setLink(null)
// I think bootstrap messes with the focus on close so we have to do this ourselves
setTimeout(() => editor.focus(), 100)
}}
>X
</div>
<Modal.Body>
<Form
initial={{
text: link?.text,
url: link?.url
}}
schema={namedUrlSchema}
onSubmit={async ({ text, url }) => {
editor.dispatchCommand(INSERT_LINK_COMMAND, { url: ensureProtocol(url), text })
await setLink(null)
setTimeout(() => editor.focus(), 100)
}}
>
<Input
label='text'
name='text'
innerRef={inputRef}
required
/>
<Input
label='url'
name='url'
required
/>
<div className='d-flex'>
<SubmitButton variant='success' className='ml-auto mt-1 px-4'>ok</SubmitButton>
</div>
</Form>
</Modal.Body>
</Modal>
)
}

View File

@ -1,232 +0,0 @@
import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import styles from '../styles.module.css'
import {
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_HIGH,
COMMAND_PRIORITY_LOW,
KEY_ESCAPE_COMMAND,
SELECTION_CHANGE_COMMAND
} from 'lexical'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as React from 'react'
import { getSelectedNode } from '../utils/selected-node'
import { setTooltipPosition } from '../utils/tooltip-position'
import { useLinkInsert } from './link-insert'
import { getLinkFromSelection } from '../utils/link-from-selection'
function FloatingLinkEditor ({
editor,
isLink,
setIsLink,
anchorElem
}) {
const { setLink } = useLinkInsert()
const editorRef = useRef(null)
const inputRef = useRef(null)
const [linkUrl, setLinkUrl] = useState('')
const [isEditMode, setEditMode] = useState(false)
const updateLinkEditor = useCallback(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection)
const parent = node.getParent()
if ($isLinkNode(parent)) {
setLinkUrl(parent.getURL())
} else if ($isLinkNode(node)) {
setLinkUrl(node.getURL())
} else {
setLinkUrl('')
}
}
const editorElem = editorRef.current
const nativeSelection = window.getSelection()
const activeElement = document.activeElement
if (editorElem === null) {
return
}
const rootElement = editor.getRootElement()
if (
selection !== null &&
nativeSelection !== null &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode) &&
editor.isEditable()
) {
const domRange = nativeSelection.getRangeAt(0)
let rect
if (nativeSelection.anchorNode === rootElement) {
let inner = rootElement
while (inner.firstElementChild != null) {
inner = inner.firstElementChild
}
rect = inner.getBoundingClientRect()
} else {
rect = domRange.getBoundingClientRect()
}
setTooltipPosition(rect, editorElem, anchorElem)
} else if (!activeElement) {
if (rootElement !== null) {
setTooltipPosition(null, editorElem, anchorElem)
}
setEditMode(false)
setLinkUrl('')
}
return true
}, [anchorElem, editor])
useEffect(() => {
const scrollerElem = anchorElem.parentElement
const update = () => {
editor.getEditorState().read(() => {
updateLinkEditor()
})
}
window.addEventListener('resize', update)
if (scrollerElem) {
scrollerElem.addEventListener('scroll', update)
}
return () => {
window.removeEventListener('resize', update)
if (scrollerElem) {
scrollerElem.removeEventListener('scroll', update)
}
}
}, [anchorElem.parentElement, editor, updateLinkEditor])
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateLinkEditor()
})
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateLinkEditor()
return true
},
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
() => {
if (isLink) {
setIsLink(false)
return true
}
return false
},
COMMAND_PRIORITY_HIGH
)
)
}, [editor, updateLinkEditor, setIsLink, isLink])
useEffect(() => {
editor.getEditorState().read(() => {
updateLinkEditor()
})
}, [editor, updateLinkEditor])
useEffect(() => {
if (isEditMode && inputRef.current) {
inputRef.current.focus()
}
}, [isEditMode])
return (
linkUrl &&
<div ref={editorRef} className={styles.linkTooltip}>
<div className='tooltip-inner d-flex'>
<a href={linkUrl} target='_blank' rel='noreferrer' className={`${styles.tooltipUrl} text-reset`}>{linkUrl.replace('https://', '').replace('http://', '')}</a>
<span className='px-1'> \ </span>
<span
className='pointer'
onClick={() => {
editor.update(() => {
// we need to replace the link
// their playground simple 'TOGGLE's it with a new url
// but we need to potentiallyr replace the text
setLink(getLinkFromSelection())
})
}}
>edit
</span>
<span className='px-1'> \ </span>
<span
className='pointer'
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}}
>remove
</span>
</div>
</div>
)
}
function useFloatingLinkEditorToolbar ({ editor, anchorElem }) {
const [activeEditor, setActiveEditor] = useState(editor)
const [isLink, setIsLink] = useState(false)
const updateToolbar = useCallback(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection)
const linkParent = $findMatchingParent(node, $isLinkNode)
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode)
// We don't want this menu to open for auto links.
if (linkParent != null && autoLinkParent == null) {
setIsLink(true)
} else {
setIsLink(false)
}
}
}, [])
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
updateToolbar()
setActiveEditor(newEditor)
return false
},
COMMAND_PRIORITY_CRITICAL
)
}, [editor, updateToolbar])
return isLink
? <FloatingLinkEditor
editor={activeEditor}
isLink={isLink}
anchorElem={anchorElem}
setIsLink={setIsLink}
/>
: null
}
export default function LinkTooltipPlugin ({
anchorElem = document.body
}) {
const [editor] = useLexicalComposerContext()
return useFloatingLinkEditorToolbar({ editor, anchorElem })
}

Some files were not shown because too many files have changed in this diff Show More